Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
1 line
3.1 MiB
Plaintext
1 line
3.1 MiB
Plaintext
{"repo": "7shiki3/decepticon-x-darktriad", "files": {"clients/launcher/cmd/onboard.go": {"42b918525fb79065e730da3da997bffb33fcd414": {"t": 1785745650.5119848, "n": 46831, "h": "3e33420922c41b34", "c": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/huh/v2\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/cmd/opscontrol\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/migrate\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/platform\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/starprompt\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui\"\n\t\"github.com/spf13/cobra\"\n)\n\n// onboardOllamaProbeBudget bounds the wait for the background Ollama\n// discovery probe at form-construction time. Per-request timeout\n// (ollama_models.go) is much shorter; this is the worst-case across\n// all candidate URLs.\nconst onboardOllamaProbeBudget = 3 * time.Second\n\nvar resetFlag bool\n\nvar onboardCmd = &cobra.Command{\n\tUse: \"onboard\",\n\tShort: \"Configure Decepticon (auth methods, model profile, observability)\",\n\tRunE: runOnboard,\n}\n\nfunc init() {\n\tonboardCmd.Flags().BoolVar(&resetFlag, \"reset\", false, \"Reconfigure even if .env already exists\")\n\trootCmd.AddCommand(onboardCmd)\n}\n\n// claudeSetupTokenDocsURL is the official Claude Code page covering\n// `claude setup-token` (mint a long-lived 1-year OAuth token for headless use).\nconst claudeSetupTokenDocsURL = \"https://code.claude.com/docs/en/authentication\"\n\n// AuthMethod identifiers — must match decepticon/llm/models.py::AuthMethod.\nconst (\n\tmethodAnthropicOAuth = \"anthropic_oauth\"\n\tmethodAnthropicAPI = \"anthropic_api\"\n\tmethodOpenAIOAuth = \"openai_oauth\"\n\tmethodOpenAIAPI = \"openai_api\"\n\tmethodGoogleOAuth = \"google_oauth\"\n\tmethodGoogleAPI = \"google_api\"\n\tmethodMiniMaxAPI = \"minimax_api\"\n\tmethodDeepSeekAPI = \"deepseek_api\"\n\tmethodXAIAPI = \"xai_api\"\n\tmethodGrokOAuth = \"grok_oauth\"\n\tmethodMistralAPI = \"mistral_api\"\n\tmethodOpenRouterAPI = \"openrouter_api\"\n\tmethodNvidiaAPI = \"nvidia_api\"\n\tmethodCopilotOAuth = \"copilot_oauth\"\n\tmethodPerplexityOAuth = \"perplexity_oauth\"\n\tmethodOllamaLocal = \"ollama_local\"\n\tmethodOllamaCloud = \"ollama_cloud\"\n\t// Cloud gateways added in the OpenClaude provider migration.\n\tmethodBedrockAPI = \"bedrock_api\"\n\tmethodVertexAPI = \"vertex_api\"\n\tmethodAzureAPI = \"azure_api\"\n\tmethodGroqAPI = \"groq_api\"\n\tmethodTogetherAPI = \"together_api\"\n\tmethodFireworksAPI = \"fireworks_api\"\n\tmethodCohereAPI = \"cohere_api\"\n\tmethodMoonshotAPI = \"moonshot_api\"\n\tmethodZaiAPI = \"zai_api\"\n\tmethodDashscopeAPI = \"dashscope_api\"\n\tmethodGitHubModelsAPI = \"github_models_api\"\n\tmethodLMStudioLocal = \"lmstudio_local\"\n\tmethodCustomOpenAIAPI = \"custom_openai_api\"\n)\n\n// Defaults shown in form placeholders for the new providers.\nconst (\n\tdefaultLMStudioAPIBase = \"http://host.docker.internal:1234/v1\"\n\tdefaultLMStudioModel = \"qwen2.5-coder-7b-instruct\"\n\tdefaultAzureAPIVersion = \"2024-08-01-preview\"\n)\n\n// Default Ollama wiring shown to OSS users. `host.docker.internal`\n// is the universal answer regardless of where Ollama runs (macOS host,\n// WSL2 distro, native Linux): from inside Decepticon's containers it\n// resolves to the host network namespace via the\n// `extra_hosts: [host.docker.internal:host-gateway]` entry on the\n// litellm service in docker-compose.yml. `localhost` is never the\n// right answer here — that's the container itself.\n//\n// The host's Ollama must additionally be bound to 0.0.0.0 (the default\n// 127.0.0.1 binding only accepts host-side connections); the wizard\n// surfaces this requirement to the user.\n//\n// The default model is the smallest/fastest one most laptops can\n// actually run; users with a GPU will pick something like qwen3-coder:30b.\nconst (\n\tdefaultOllamaAPIBase = \"http://host.docker.internal:11434\"\n\tdefaultOllamaModel = \"llama3.2\"\n)\n\n// methodOrder is the priority order surfaced in the wizard. The\n// resulting DECEPTICON_AUTH_PRIORITY preserves this order, filtered\n// to the methods the user actually selected. OAuth precedes the\n// matching API on purpose: a subscription primary should fall back\n// to the paid API only when the subscription quota is exhausted.\n// Ollama sits last: cloud providers are usually preferred when both\n// are configured, but a user wanting to lead with local-only inference\n// can reorder the priority manually in .env.\nvar methodOrder = []string{\n\tmethodAnthropicOAuth,\n\tmethodAnthropicAPI,\n\tmethodOpenAIOAuth,\n\tmethodOpenAIAPI,\n\tmethodGoogleOAuth,\n\tmethodGoogleAPI,\n\tmethodMiniMaxAPI,\n\tmethodDeepSeekAPI,\n\tmethodXAIAPI,\n\tmethodGrokOAuth,\n\tmethodMistralAPI,\n\tmethodOpenRouterAPI,\n\tmethodNvidiaAPI,\n\tmethodCopilotOAuth,\n\tmethodPerplexityOAuth,\n\t// Cloud gateways — anthropic-via-cloud paths first, then\n\t// other multi-vendor hubs.\n\tmethodBedrockAPI,\n\tmethodVertexAPI,\n\tmethodAzureAPI,\n\tmethodGitHubModelsAPI,\n\tmethodGroqAPI,\n\tmethodTogetherAPI,\n\tmethodFireworksAPI,\n\tmethodCohereAPI,\n\tmethodMoonshotAPI,\n\tmethodZaiAPI,\n\tmethodDashscopeAPI,\n\t// Local last so cloud-preferred default still picks remote\n\t// providers as primary; users can re-order in .env.\n\tmethodLMStudioLocal,\n\tmethodOllamaLocal,\n\tmethodOllamaCloud,\n\tmethodCustomOpenAIAPI,\n}\n\n// systemCheckGroup renders the host-environment snapshot as the first\n// step of onboarding. It detects OS/architecture (so the user sees the\n// machine is recognized — be it Windows, macOS, a Linux pentest distro,\n// or a Raspberry Pi) and the Docker readiness state, with OS-specific\n// remediation when something is missing. It never blocks: credentials\n// can be configured before Docker is sorted out.\nfunc systemCheckGroup(sys platform.SystemInfo) *huh.Group {\n\tvar b strings.Builder\n\n\tosLine := sys.OSLabel() + \" (\" + sys.Arch + \")\"\n\tif sys.IsWSL {\n\t\tosLine += \" · WSL2\"\n\t}\n\tb.WriteString(\"OS \" + osLine + \"\\n\")\n\n\tdocker := \"not installed\"\n\tif sys.DockerInstalled {\n\t\tdocker = \"installed\"\n\t\tif sys.DockerRunning {\n\t\t\tdocker += \" · running\"\n\t\t} else {\n\t\t\tdocker += \" · stopped\"\n\t\t}\n\t\tif sys.ComposeAvailable {\n\t\t\tdocker += \" · compose v2\"\n\t\t}\n\t}\n\tb.WriteString(\"Docker \" + docker + \"\\n\\n\")\n\n\tif sys.Ready() {\n\t\tb.WriteString(\"This system is ready to run Decepticon.\")\n\t} else {\n\t\tb.WriteString(sys.DockerHint() + \"\\n\")\n\t\tb.WriteString(\"You can still configure credentials now and sort out Docker before launching.\")\n\t}\n\n\treturn huh.NewGroup(\n\t\thuh.NewNote().\n\t\t\tTitle(\"System Check\").\n\t\t\tDescription(b.String()),\n\t)\n}\n\nfunc runOnboard(cmd *cobra.Command, args []string) error {\n\tif config.EnvExists() && !resetFlag {\n\t\tui.Info(\".env already configured at \" + config.EnvPath())\n\t\tui.DimText(\"Run 'decepticon onboard --reset' to reconfigure\")\n\t\treturn nil\n\t}\n\n\t// Kick off Ollama discovery in the background so the network\n\t// round-trip overlaps with huh's startup work; the OLLAMA_MODEL\n\t// field type depends on the result (Select vs remediation Note).\n\tprobeCh := make(chan ollamaProbeResult, 1)\n\tgo func() {\n\t\tprobeCh <- probeOllamaForOnboard(defaultOllamaAPIBase)\n\t}()\n\n\tvar (\n\t\tmethods []string\n\t\tanthropicKey string\n\t\tclaudeOAuthToken string\n\t\topenaiKey string\n\t\tgeminiKey string\n\t\tminimaxKey string\n\t\tdeepseekKey string\n\t\txaiKey string\n\t\tmistralKey string\n\t\topenrouterKey string\n\t\tnvidiaKey string\n\t\tgeminiSessionCookies string\n\t\tcopilotRefreshToken string\n\t\tgrokSessionToken string\n\t\tperplexitySessionToken string\n\t\tollamaAPIBase = defaultOllamaAPIBase\n\t\tollamaModel = defaultOllamaModel\n\t\t// Cloud gateways\n\t\tawsAccessKeyID string\n\t\tawsSecretAccessKey string\n\t\tawsRegion = \"us-east-1\"\n\t\tvertexCredsPath string\n\t\tvertexProject string\n\t\tvertexLocation = \"us-central1\"\n\t\tazureAPIKey string\n\t\tazureAPIBase string\n\t\tazureAPIVersion = defaultAzureAPIVersion\n\t\tgroqKey string\n\t\ttogetherKey string\n\t\tfireworksKey string\n\t\tcohereKey string\n\t\tmoonshotKey string\n\t\tzaiKey string\n\t\tdashscopeKey string\n\t\tgithubToken string\n\t\t// Cloud Ollama\n\t\tollamaCloudAPIBase string\n\t\tollamaCloudAPIKey string\n\t\tollamaCloudModel string\n\t\t// LM Studio (local OpenAI-compatible)\n\t\tlmStudioAPIBase = defaultLMStudioAPIBase\n\t\tlmStudioModel = defaultLMStudioModel\n\t\t// Custom OpenAI-compatible endpoint\n\t\tcustomOpenAIAPIBase string\n\t\tcustomOpenAIAPIKey string\n\t\tcustomOpenAIModel string\n\t\tprofile string\n\t\tlanguage = \"en\"\n\t\tuseLangSmith bool\n\t\tlangSmithKey string\n\t\ttelemetryConsent = true // opt-out: default to sharing (masked)\n\t)\n\t// Block on the probe (zero-value result on timeout means\n\t// \"unreachable\" — drops through to the remediation Note).\n\t// time.NewTimer + Stop avoids the time.After timer leak when the\n\t// probe finishes first.\n\tprobeTimer := time.NewTimer(onboardOllamaProbeBudget)\n\tvar ollamaProbe ollamaProbeResult\n\tselect {\n\tcase ollamaProbe = <-probeCh:\n\t\tprobeTimer.Stop()\n\tcase <-probeTimer.C:\n\t}\n\tollamaModelField := buildOllamaModelField(ollamaProbe, &ollamaModel)\n\n\t// Probe the host environment so the wizard can confirm — up front —\n\t// that this machine (whatever OS/arch it is) can actually run the\n\t// Docker stack, and surface remediation before the user spends time\n\t// entering credentials.\n\tsysInfo := platform.Detect()\n\n\tform := huh.NewForm(\n\t\t// Step 0: system check.\n\t\tsystemCheckGroup(sysInfo),\n\n\t\t// Intro\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Decepticon Setup\").\n\t\t\t\tDescription(\"Configure auth methods, model profile, and\\nobservability.\\n\\nUse ↑↓ to navigate, space to toggle, Enter to confirm.\"),\n\t\t),\n\n\t\t// Step 1: Auth methods (multi-select)\n\t\thuh.NewGroup(\n\t\t\thuh.NewMultiSelect[string]().\n\t\t\t\tTitle(\"Auth Methods\").\n\t\t\t\tDescription(\"Press SPACE to toggle, ENTER to confirm.\\nUse ↑↓ to scroll, '/' to filter the list.\\nPick every credential you have — each is an\\nindependent fallback in priority order.\").\n\t\t\t\tFilterable(true).\n\t\t\t\tHeight(15).\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"Claude Code OAuth — Anthropic subscription (auth/*)\", methodAnthropicOAuth),\n\t\t\t\t\thuh.NewOption(\"Anthropic API Key — sk-ant-...\", methodAnthropicAPI),\n\t\t\t\t\thuh.NewOption(\"ChatGPT OAuth — ChatGPT Pro/Plus/Team subscription via `codex login` (auth/gpt-*)\", methodOpenAIOAuth),\n\t\t\t\t\thuh.NewOption(\"OpenAI API Key — sk-...\", methodOpenAIAPI),\n\t\t\t\t\thuh.NewOption(\"Google API Key — AIza... (Gemini)\", methodGoogleAPI),\n\t\t\t\t\thuh.NewOption(\"MiniMax API Key — eyJ...\", methodMiniMaxAPI),\n\t\t\t\t\thuh.NewOption(\"DeepSeek API Key — sk-...\", methodDeepSeekAPI),\n\t\t\t\t\thuh.NewOption(\"xAI API Key — xai-... (Grok)\", methodXAIAPI),\n\t\t\t\t\thuh.NewOption(\"Mistral API Key — (no fixed prefix)\", methodMistralAPI),\n\t\t\t\t\thuh.NewOption(\"OpenRouter API Key — sk-or-...\", methodOpenRouterAPI),\n\t\t\t\t\thuh.NewOption(\"Nvidia NIM API Key — nvapi-...\", methodNvidiaAPI),\n\t\t\t\t\thuh.NewOption(\"Gemini Advanced — Google One AI Premium subscription (gemini-sub/*)\", methodGoogleOAuth),\n\t\t\t\t\thuh.NewOption(\"SuperGrok — X Premium+ Grok subscription (grok-sub/*)\", methodGrokOAuth),\n\t\t\t\t\thuh.NewOption(\"GitHub Copilot Pro — Copilot subscription (copilot/*)\", methodCopilotOAuth),\n\t\t\t\t\thuh.NewOption(\"Perplexity Pro — Perplexity subscription (pplx-sub/*)\", methodPerplexityOAuth),\n\t\t\t\t\thuh.NewOption(\"AWS Bedrock — Anthropic models on AWS (bedrock/*)\", methodBedrockAPI),\n\t\t\t\t\thuh.NewOption(\"GCP Vertex AI — Anthropic + Gemini on GCP (vertex_ai/*)\", methodVertexAPI),\n\t\t\t\t\thuh.NewOption(\"Azure OpenAI — Azure-hosted GPT deployments (azure/*)\", methodAzureAPI),\n\t\t\t\t\thuh.NewOption(\"GitHub Models — GPT family via GitHub PAT (github/*)\", methodGitHubModelsAPI),\n\t\t\t\t\thuh.NewOption(\"Groq Cloud — LPU-accelerated Llama (groq/*)\", methodGroqAPI),\n\t\t\t\t\thuh.NewOption(\"Together AI — Llama / Mixtral hub (together_ai/*)\", methodTogetherAPI),\n\t\t\t\t\thuh.NewOption(\"Fireworks AI — Llama / Mixtral hub (fireworks_ai/*)\", methodFireworksAPI),\n\t\t\t\t\thuh.NewOption(\"Cohere Command — Command-A / Command-R (cohere/*)\", methodCohereAPI),\n\t\t\t\t\thuh.NewOption(\"Moonshot Kimi K2 — Kimi K2 (moonshot/*)\", methodMoonshotAPI),\n\t\t\t\t\thuh.NewOption(\"Z.ai GLM-4.5 — GLM-4.5 / GLM-4.5-Air (zai/*)\", methodZaiAPI),\n\t\t\t\t\thuh.NewOption(\"Alibaba DashScope — Qwen Max/Plus/Turbo (dashscope/*)\", methodDashscopeAPI),\n\t\t\t\t\thuh.NewOption(\"LM Studio (local) — local OpenAI-compatible server (lm_studio/*)\", methodLMStudioLocal),\n\t\t\t\t\thuh.NewOption(\"Local LLM (Ollama) — any pulled model, no API key\", methodOllamaLocal),\n\t\t\t\t\thuh.NewOption(\"Ollama Cloud — hosted Ollama (ollama_chat/* via cloud)\", methodOllamaCloud),\n\t\t\t\t\thuh.NewOption(\"Custom OpenAI Endpoint — bring-your-own OpenAI-compatible URL\", methodCustomOpenAIAPI),\n\t\t\t\t).\n\t\t\t\tValue(&methods).\n\t\t\t\tValidate(func(s []string) error {\n\t\t\t\t\tif len(s) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"select at least one credential\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"1 / 5 · Credentials\").\n\t\t\tDescription(\"Select all that apply\"),\n\n\t\t// Step 2-claude-oauth: Claude Code subscription long-lived token.\n\t\t// Run `claude setup-token` once (any machine, interactive browser\n\t\t// OAuth) → paste the 1-year token here. It is honored via the\n\t\t// ANTHROPIC_OAUTH_TOKEN env override, which carries expiresAt=0 so it\n\t\t// is never auto-refreshed — headless-safe for 24/7 / cloud, with no\n\t\t// live Claude Code session and no ~/.claude/.credentials.json mount.\n\t\t// Optional: leave blank to fall back to the rotating on-disk\n\t\t// credentials from an interactive `claude` login.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Claude Code Subscription — 1-year token\").\n\t\t\t\tDescription(\n\t\t\t\t\t\"Two steps:\\n\\n\"+\n\t\t\t\t\t\t\"1) Generate a 1-year token — run:\\n\"+\n\t\t\t\t\t\t\" claude setup-token\\n\"+\n\t\t\t\t\t\t\" (opens browser OAuth; needs a Pro / Max / Team /\\n\"+\n\t\t\t\t\t\t\" Enterprise plan). Docs / issue link:\\n\"+\n\t\t\t\t\t\t\" \"+claudeSetupTokenDocsURL+\"\\n\\n\"+\n\t\t\t\t\t\t\"2) Paste the sk-ant-oat01-… token it prints into the\\n\"+\n\t\t\t\t\t\t\" field below.\\n\\n\"+\n\t\t\t\t\t\t\"Headless-safe: never refreshed, no live Claude Code\\n\"+\n\t\t\t\t\t\t\"session. Leave blank to use a rotating\\n\"+\n\t\t\t\t\t\t\"~/.claude/.credentials.json from a `claude` login.\",\n\t\t\t\t),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"ANTHROPIC_OAUTH_TOKEN\").\n\t\t\t\tPlaceholder(\"sk-ant-oat01-... (leave blank to use credentials file)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&claudeOAuthToken).\n\t\t\t\tValidate(optionalClaudeOAuthToken),\n\t\t).Title(\"2 / 5 · Claude Code Subscription\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicOAuth) }),\n\n\t\t// Step 2a: Anthropic API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Anthropic API Key\").\n\t\t\t\tPlaceholder(\"sk-ant-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&anthropicKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Anthropic API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicAPI) }),\n\n\t\t// Step 2c: OpenAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenAI API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenAI API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIAPI) }),\n\n\t\t// Step 2c: Google API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Google (Gemini) API Key\").\n\t\t\t\tPlaceholder(\"AIza...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Google API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleAPI) }),\n\n\t\t// Step 2d: MiniMax API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MiniMax API Key\").\n\t\t\t\tPlaceholder(\"eyJ...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&minimaxKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · MiniMax API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMiniMaxAPI) }),\n\n\t\t// Step 2d-i: DeepSeek API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DeepSeek API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&deepseekKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DeepSeek API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDeepSeekAPI) }),\n\n\t\t// Step 2d-ii: xAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"xAI API Key\").\n\t\t\t\tPlaceholder(\"xai-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&xaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · xAI API (Grok)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodXAIAPI) }),\n\n\t\t// Step 2d-iii: Mistral API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Mistral API Key\").\n\t\t\t\tPlaceholder(\"paste your Mistral API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&mistralKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Mistral API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMistralAPI) }),\n\n\t\t// Step 2e: OpenRouter API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenRouter API Key\").\n\t\t\t\tPlaceholder(\"sk-or-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openrouterKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenRouter API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenRouterAPI) }),\n\n\t\t// Step 2f: Nvidia NIM API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Nvidia NIM API Key\").\n\t\t\t\tPlaceholder(\"nvapi-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&nvidiaKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Nvidia NIM API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodNvidiaAPI) }),\n\n\t\t// Step 2-oauth-i: Gemini Advanced subscription\n\t\t// Multi-cookie value (NID + Secure-1PSID + Secure-1PSIDTS, etc.)\n\t\t// joined with semicolons. Optional — power users can drop a\n\t\t// tokens.json under ~/.config/gemini/ instead and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Gemini Advanced Subscription\").\n\t\t\t\tDescription(\"Open gemini.google.com → DevTools → Application →\\nCookies → gemini.google.com. Copy the values of\\nNID, __Secure-1PSID, __Secure-1PSIDTS as a single\\nsemicolon-joined string. Or leave blank to use\\nGEMINI_ACCESS_TOKEN / ~/.config/gemini/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GEMINI_SESSION_COOKIES\").\n\t\t\t\tPlaceholder(\"NID=...; __Secure-1PSID=... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiSessionCookies),\n\t\t).Title(\"2 / 5 · Gemini Advanced\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleOAuth) }),\n\n\t\t// Step 2-oauth-ii: SuperGrok subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"SuperGrok Subscription\").\n\t\t\t\tDescription(\"Open grok.com → DevTools → Application →\\nCookies → grok.com → copy the value of\\n`auth_token`. Or leave blank to use\\nGROK_ACCESS_TOKEN / ~/.config/grok/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROK_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste auth_token cookie value (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&grokSessionToken),\n\t\t).Title(\"2 / 5 · SuperGrok\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGrokOAuth) }),\n\n\t\t// Step 2-oauth-iii: GitHub Copilot Pro subscription\n\t\t// Refresh token rotation, not a single session cookie. Devs can\n\t\t// extract one from VSCode's Copilot extension config or follow\n\t\t// gh-copilot-cli onboarding. Optional via tokens.json fallback.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GitHub Copilot Pro Subscription\").\n\t\t\t\tDescription(\"Provide a Copilot refresh token (long-lived, used\\nto rotate access tokens). Or leave blank to use\\nCOPILOT_ACCESS_TOKEN / ~/.config/copilot/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COPILOT_REFRESH_TOKEN\").\n\t\t\t\tPlaceholder(\"ghu_... or ghr_... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&copilotRefreshToken),\n\t\t).Title(\"2 / 5 · Copilot Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCopilotOAuth) }),\n\n\t\t// Step 2-oauth-iv: Perplexity Pro subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Perplexity Pro Subscription\").\n\t\t\t\tDescription(\"Open perplexity.ai → DevTools → Application →\\nCookies → perplexity.ai → copy the value of\\n`next-auth.session-token`. Or leave blank to use\\nPERPLEXITY_ACCESS_TOKEN / ~/.config/perplexity/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"PERPLEXITY_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste next-auth.session-token (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&perplexitySessionToken),\n\t\t).Title(\"2 / 5 · Perplexity Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodPerplexityOAuth) }),\n\n\t\t// Step 2g: Local Ollama. The OLLAMA_MODEL field is built from\n\t\t// the host probe (buildOllamaModelField): a strict Select when\n\t\t// tool-capable models are pulled, a remediation Note otherwise.\n\t\t// The post-form gate refuses to write .env in the Note cases.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Local Ollama\").\n\t\t\t\tDescription(\"Ollama must already be running on your host with\\nat least one tool-capable model pulled. Launch it\\nbound to all interfaces so the Decepticon container\\ncan reach it:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\\nThe default 127.0.0.1 binding only accepts host-side\\nconnections — containers won't see it.\\n\\nThe default URL `host.docker.internal:11434` works\\non macOS, Linux, and WSL2 (with or without Docker\\nDesktop). Only change it for remote / custom setups.\\nNote: the model list is probed against the default URL\\nat wizard start; if you customize OLLAMA_API_BASE,\\nfinish the wizard then re-run 'decepticon onboard\\n--reset' so the probe targets the new endpoint.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_API_BASE\").\n\t\t\t\tPlaceholder(defaultOllamaAPIBase).\n\t\t\t\tValue(&ollamaAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\tollamaModelField,\n\t\t).Title(\"2 / 5 · Local LLM (Ollama)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaLocal) }),\n\n\t\t// Step 2-cloud-i: AWS Bedrock — three-field group (key + secret + region)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"AWS Bedrock\").\n\t\t\t\tDescription(\"Bedrock uses AWS SigV4 — provide IAM access key,\\nsecret, and the region where Anthropic models are\\nenabled (us-east-1 or us-west-2 typically).\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_ACCESS_KEY_ID\").\n\t\t\t\tPlaceholder(\"AKIA...\").\n\t\t\t\tValue(&awsAccessKeyID).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_SECRET_ACCESS_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&awsSecretAccessKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_REGION\").\n\t\t\t\tPlaceholder(\"us-east-1\").\n\t\t\t\tValue(&awsRegion).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · AWS Bedrock\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodBedrockAPI) }),\n\n\t\t// Step 2-cloud-ii: GCP Vertex AI — service-account JSON path + project + region\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GCP Vertex AI\").\n\t\t\t\tDescription(\"Vertex AI uses a Google Cloud service-account JSON.\\nDownload one from IAM & Admin → Service Accounts and\\npaste the absolute file path. Project + region must\\nmatch where you enabled Anthropic + Gemini models.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GOOGLE_APPLICATION_CREDENTIALS\").\n\t\t\t\tPlaceholder(\"/abs/path/to/service-account.json\").\n\t\t\t\tValue(&vertexCredsPath).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"VERTEXAI_PROJECT\").\n\t\t\t\tPlaceholder(\"my-gcp-project-id\").\n\t\t\t\tValue(&vertexProject).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"VERTEXAI_LOCATION\").\n\t\t\t\tPlaceholder(\"us-central1\").\n\t\t\t\tValue(&vertexLocation).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · GCP Vertex AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodVertexAPI) }),\n\n\t\t// Step 2-cloud-iii: Azure OpenAI — key + endpoint + version\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Azure OpenAI Service\").\n\t\t\t\tDescription(\"Endpoint is your azure-openai resource URL,\\ne.g. https://my-resource.openai.azure.com.\\nThe deployment names map 1:1 to gpt-5.5/gpt-5.4/...\\nso the model matrix in DF resolves out of the box.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&azureAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_BASE\").\n\t\t\t\tPlaceholder(\"https://<resource>.openai.azure.com\").\n\t\t\t\tValue(&azureAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_VERSION\").\n\t\t\t\tPlaceholder(defaultAzureAPIVersion).\n\t\t\t\tValue(&azureAPIVersion).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Azure OpenAI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAzureAPI) }),\n\n\t\t// Step 2-cloud-iv: GitHub Models — PAT\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GITHUB_TOKEN\").\n\t\t\t\tDescription(\"Fine-grained PAT with the 'models' permission scope.\\nGenerate at github.com/settings/personal-access-tokens.\").\n\t\t\t\tPlaceholder(\"github_pat_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&githubToken).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · GitHub Models\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGitHubModelsAPI) }),\n\n\t\t// Step 2-cloud-v: Groq\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROQ_API_KEY\").\n\t\t\t\tPlaceholder(\"gsk_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&groqKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Groq Cloud\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGroqAPI) }),\n\n\t\t// Step 2-cloud-vi: Together AI\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"TOGETHER_API_KEY\").\n\t\t\t\tPlaceholder(\"paste your Together AI API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&togetherKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Together AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodTogetherAPI) }),\n\n\t\t// Step 2-cloud-vii: Fireworks AI\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"FIREWORKS_API_KEY\").\n\t\t\t\tPlaceholder(\"fw_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&fireworksKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Fireworks AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodFireworksAPI) }),\n\n\t\t// Step 2-cloud-viii: Cohere\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COHERE_API_KEY\").\n\t\t\t\tPlaceholder(\"paste your Cohere API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&cohereKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Cohere\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCohereAPI) }),\n\n\t\t// Step 2-cloud-ix: Moonshot Kimi K2\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MOONSHOT_API_KEY\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&moonshotKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Moonshot Kimi K2\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMoonshotAPI) }),\n\n\t\t// Step 2-cloud-x: Z.ai GLM-4.5\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"ZAI_API_KEY\").\n\t\t\t\tDescription(\"Z.ai is OpenAI-compatible — base URL is hard-wired to\\nhttps://api.z.ai/api/paas/v4. Get a key at z.ai/manage.\").\n\t\t\t\tPlaceholder(\"paste your Z.ai API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&zaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Z.ai GLM-4.5\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodZaiAPI) }),\n\n\t\t// Step 2-cloud-xi: Alibaba DashScope (Qwen)\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DASHSCOPE_API_KEY\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&dashscopeKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DashScope (Qwen)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDashscopeAPI) }),\n\n\t\t// Step 2-local-ii: LM Studio (local OpenAI-compatible)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"LM Studio\").\n\t\t\t\tDescription(\"LM Studio runs a local OpenAI-compatible server.\\nLaunch the LM Studio app and start the server\\n(Developer tab → Start). The default port is 1234.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LMSTUDIO_API_BASE\").\n\t\t\t\tPlaceholder(defaultLMStudioAPIBase).\n\t\t\t\tValue(&lmStudioAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LMSTUDIO_MODEL\").\n\t\t\t\tDescription(\"Model identifier as shown in LM Studio (e.g.\\nqwen2.5-coder-7b-instruct).\").\n\t\t\t\tPlaceholder(defaultLMStudioModel).\n\t\t\t\tValue(&lmStudioModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · LM Studio\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodLMStudioLocal) }),\n\n\t\t// Step 2-local-iii: Ollama Cloud (hosted)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Ollama Cloud\").\n\t\t\t\tDescription(\"Hosted Ollama — same /api/chat tool-calling endpoint\\nas local Ollama, just behind an API key.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_API_BASE\").\n\t\t\t\tPlaceholder(\"https://api.ollama.com/v1\").\n\t\t\t\tValue(&ollamaCloudAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&ollamaCloudAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_MODEL\").\n\t\t\t\tPlaceholder(\"llama3.3:70b\").\n\t\t\t\tValue(&ollamaCloudModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Ollama Cloud\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaCloud) }),\n\n\t\t// Step 2-custom: Custom OpenAI-compatible endpoint\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Custom OpenAI-compatible Endpoint\").\n\t\t\t\tDescription(\"Point at any OpenAI-compatible server (gateways,\\nself-hosted vLLM, internal LiteLLM, etc.). The model\\nname is sent verbatim to the upstream — match what\\nyour gateway exposes.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_API_BASE\").\n\t\t\t\tPlaceholder(\"https://gateway.example.com/v1\").\n\t\t\t\tValue(&customOpenAIAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&customOpenAIAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_MODEL\").\n\t\t\t\tPlaceholder(\"gpt-4o-mini\").\n\t\t\t\tValue(&customOpenAIModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Custom OpenAI Endpoint\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCustomOpenAIAPI) }),\n\n\t\t// Step 3: Model profile\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Model Profile\").\n\t\t\t\tDescription(\"eco per-agent tier (recommended)\\nmax every agent on HIGH (expensive)\\ntest every agent on LOW (development)\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"eco — per-agent tier (recommended)\", \"eco\"),\n\t\t\t\t\thuh.NewOption(\"max — every agent on HIGH (expensive)\", \"max\"),\n\t\t\t\t\thuh.NewOption(\"test — every agent on LOW (development)\", \"test\"),\n\t\t\t\t).\n\t\t\t\tValue(&profile),\n\t\t).Title(\"3 / 5 · Profile\"),\n\n\t\t// Step 4: Language\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Agent Language\").\n\t\t\t\tDescription(\"Language for all agent prose output (menus, questions,\\nsummaries, errors). Technical output stays in English.\\nCountry codes (dk, se, jp, cn) are auto-resolved.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"en — English (default)\", \"en\"),\n\t\t\t\t\thuh.NewOption(\"no — Norwegian\", \"no\"),\n\t\t\t\t\thuh.NewOption(\"da — Danish\", \"da\"),\n\t\t\t\t\thuh.NewOption(\"sv — Swedish\", \"sv\"),\n\t\t\t\t\thuh.NewOption(\"fi — Finnish\", \"fi\"),\n\t\t\t\t\thuh.NewOption(\"is — Icelandic\", \"is\"),\n\t\t\t\t\thuh.NewOption(\"ko — Korean\", \"ko\"),\n\t\t\t\t\thuh.NewOption(\"ja — Japanese\", \"ja\"),\n\t\t\t\t\thuh.NewOption(\"zh — Chinese\", \"zh\"),\n\t\t\t\t\thuh.NewOption(\"zh-tw — Traditional Chinese\", \"zh-tw\"),\n\t\t\t\t\thuh.NewOption(\"es — Spanish\", \"es\"),\n\t\t\t\t\thuh.NewOption(\"pt — Portuguese\", \"pt\"),\n\t\t\t\t\thuh.NewOption(\"pt-br — Brazilian Portuguese\", \"pt-br\"),\n\t\t\t\t\thuh.NewOption(\"de — German\", \"de\"),\n\t\t\t\t\thuh.NewOption(\"fr — French\", \"fr\"),\n\t\t\t\t\thuh.NewOption(\"nl — Dutch\", \"nl\"),\n\t\t\t\t\thuh.NewOption(\"it — Italian\", \"it\"),\n\t\t\t\t\thuh.NewOption(\"pl — Polish\", \"pl\"),\n\t\t\t\t\thuh.NewOption(\"cs — Czech\", \"cs\"),\n\t\t\t\t\thuh.NewOption(\"uk — Ukrainian\", \"uk\"),\n\t\t\t\t\thuh.NewOption(\"ro — Romanian\", \"ro\"),\n\t\t\t\t\thuh.NewOption(\"hr — Croatian\", \"hr\"),\n\t\t\t\t\thuh.NewOption(\"bg — Bulgarian\", \"bg\"),\n\t\t\t\t\thuh.NewOption(\"ru — Russian\", \"ru\"),\n\t\t\t\t\thuh.NewOption(\"el — Greek\", \"el\"),\n\t\t\t\t\thuh.NewOption(\"hu — Hungarian\", \"hu\"),\n\t\t\t\t\thuh.NewOption(\"tr — Turkish\", \"tr\"),\n\t\t\t\t\thuh.NewOption(\"ar — Arabic\", \"ar\"),\n\t\t\t\t\thuh.NewOption(\"fa — Persian\", \"fa\"),\n\t\t\t\t\thuh.NewOption(\"he — Hebrew\", \"he\"),\n\t\t\t\t\thuh.NewOption(\"hi — Hindi\", \"hi\"),\n\t\t\t\t\thuh.NewOption(\"th — Thai\", \"th\"),\n\t\t\t\t\thuh.NewOption(\"vi — Vietnamese\", \"vi\"),\n\t\t\t\t\thuh.NewOption(\"id — Indonesian\", \"id\"),\n\t\t\t\t\thuh.NewOption(\"ms — Malay\", \"ms\"),\n\t\t\t\t\thuh.NewOption(\"tl — Filipino\", \"tl\"),\n\t\t\t\t\thuh.NewOption(\"sw — Swahili\", \"sw\"),\n\t\t\t\t\thuh.NewOption(\"af — Afrikaans\", \"af\"),\n\t\t\t\t\thuh.NewOption(\"wenyan — 文言文 + English technical terms\", \"wenyan\"),\n\t\t\t\t).\n\t\t\t\tValue(&language),\n\t\t).Title(\"4 / 5 · Language\"),\n\n\t\t// Step 5a: LangSmith toggle\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Enable LangSmith?\").\n\t\t\t\tDescription(\"LLM observability and trace collection\").\n\t\t\t\tAffirmative(\"Yes\").\n\t\t\t\tNegative(\"No\").\n\t\t\t\tValue(&useLangSmith),\n\t\t).Title(\"5 / 5 · Observability\"),\n\n\t\t// Step 5b: LangSmith key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LangSmith API Key\").\n\t\t\t\tPlaceholder(\"lsv2_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&langSmithKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"5 / 5 · LangSmith\").\n\t\t\tWithHideFunc(func() bool { return !useLangSmith }),\n\n\t\t// Step 5c: usage telemetry consent (opt-out — default yes)\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Share anonymous + masked red-team telemetry?\").\n\t\t\t\tDescription(\n\t\t\t\t\t\"Decepticon is free/OSS — sharing usage helps fund and improve it,\\n\"+\n\t\t\t\t\t\t\"and contributes masked red-team reasoning to train future open\\n\"+\n\t\t\t\t\t\t\"offensive-security agents.\\n\\n\"+\n\t\t\t\t\t\t\" • anonymous structural stats (tools used, finding severity/CWE,\\n\"+\n\t\t\t\t\t\t\" kill-chain phase)\\n\"+\n\t\t\t\t\t\t\" • the agent's red-team REASONING, with target identifiers MASKED\\n\"+\n\t\t\t\t\t\t\" (10.0.0.5 -> <HOST_1>, acme.com -> <DOMAIN_1>)\\n\\n\"+\n\t\t\t\t\t\t\"Never sent: raw prompts, real target IPs/hosts, credentials. Your\\n\"+\n\t\t\t\t\t\t\"IP is dropped at the gateway. Change anytime: DECEPTICON_TELEMETRY=off\\n\"+\n\t\t\t\t\t\t\"(or basic) in .env, `decepticon-cli telemetry off`, or DO_NOT_TRACK=1.\",\n\t\t\t\t).\n\t\t\t\tAffirmative(\"Yes, share (recommended)\").\n\t\t\t\tNegative(\"No, keep it off\").\n\t\t\t\tValue(&telemetryConsent),\n\t\t).Title(\"5 / 5 · Usage telemetry\"),\n\t).WithTheme(huh.ThemeFunc(ui.DecepticonTheme))\n\n\tif err := form.Run(); err != nil {\n\t\treturn fmt.Errorf(\"setup cancelled: %w\", err)\n\t}\n\n\t// Opt-out telemetry: consent maps to the research tier (masked\n\t// reasoning corpus); declining writes an explicit off.\n\ttelemetryChoice := \"research\"\n\tif !telemetryConsent {\n\t\ttelemetryChoice = \"off\"\n\t}\n\n\t// Strict-mode gate: refuse to write .env when the user picked\n\t// Ollama but the host probe found no tool-capable model. The\n\t// in-form Note shows the same remediation; this is the boundary\n\t// guarantee that a broken setup never ships.\n\tif contains(methods, methodOllamaLocal) && len(ollamaProbe.ToolCapableModels) == 0 {\n\t\treturn ollamaUnusableError(ollamaProbe, ollamaAPIBase)\n\t}\n\n\t// huh.MultiSelect returns selected values in option order, not the\n\t// order the user toggled. Re-derive the priority by walking\n\t// methodOrder and keeping only what the user picked.\n\tpriority := make([]string, 0, len(methods))\n\tfor _, m := range methodOrder {\n\t\tif contains(methods, m) {\n\t\t\tpriority = append(priority, m)\n\t\t}\n\t}\n\n\tvalues := map[string]string{\n\t\t\"DECEPTICON_MODEL_PROFILE\": profile,\n\t\t\"DECEPTICON_LANGUAGE\": language,\n\t\t\"DECEPTICON_AUTH_PRIORITY\": strings.Join(priority, \",\"),\n\t\t\"DECEPTICON_AUTH_CLAUDE_CODE\": boolStr(contains(methods, methodAnthropicOAuth)),\n\t\t\"DECEPTICON_AUTH_CHATGPT\": boolStr(contains(methods, methodOpenAIOAuth)),\n\t\t\"DECEPTICON_AUTH_GEMINI\": boolStr(contains(methods, methodGoogleOAuth)),\n\t\t\"DECEPTICON_AUTH_GROK\": boolStr(contains(methods, methodGrokOAuth)),\n\t\t\"DECEPTICON_AUTH_COPILOT\": boolStr(contains(methods, methodCopilotOAuth)),\n\t\t\"DECEPTICON_AUTH_PERPLEXITY\": boolStr(contains(methods, methodPerplexityOAuth)),\n\t}\n\n\tif anthropicKey != \"\" {\n\t\tvalues[\"ANTHROPIC_API_KEY\"] = anthropicKey\n\t}\n\tif claudeOAuthToken != \"\" {\n\t\t// Long-lived `claude setup-token`. The claude_code handler reads this\n\t\t// as a synthetic credential (expiresAt=0 → never refreshed); no\n\t\t// credentials-file mount or live CC session needed.\n\t\tvalues[\"ANTHROPIC_OAUTH_TOKEN\"] = strings.TrimSpace(claudeOAuthToken)\n\t}\n\tif openaiKey != \"\" {\n\t\tvalues[\"OPENAI_API_KEY\"] = openaiKey\n\t}\n\tif geminiKey != \"\" {\n\t\tvalues[\"GEMINI_API_KEY\"] = geminiKey\n\t}\n\tif minimaxKey != \"\" {\n\t\tvalues[\"MINIMAX_API_KEY\"] = minimaxKey\n\t}\n\tif deepseekKey != \"\" {\n\t\tvalues[\"DEEPSEEK_API_KEY\"] = deepseekKey\n\t}\n\tif xaiKey != \"\" {\n\t\tvalues[\"XAI_API_KEY\"] = xaiKey\n\t}\n\tif mistralKey != \"\" {\n\t\tvalues[\"MISTRAL_API_KEY\"] = mistralKey\n\t}\n\tif openrouterKey != \"\" {\n\t\tvalues[\"OPENROUTER_API_KEY\"] = openrouterKey\n\t}\n\tif nvidiaKey != \"\" {\n\t\tvalues[\"NVIDIA_API_KEY\"] = nvidiaKey\n\t}\n\tif geminiSessionCookies != \"\" {\n\t\tvalues[\"GEMINI_SESSION_COOKIES\"] = geminiSessionCookies\n\t}\n\tif grokSessionToken != \"\" {\n\t\tvalues[\"GROK_SESSION_TOKEN\"] = grokSessionToken\n\t}\n\tif copilotRefreshToken != \"\" {\n\t\tvalues[\"COPILOT_REFRESH_TOKEN\"] = copilotRefreshToken\n\t}\n\tif perplexitySessionToken != \"\" {\n\t\tvalues[\"PERPLEXITY_SESSION_TOKEN\"] = perplexitySessionToken\n\t}\n\tif contains(methods, methodOllamaLocal) {\n\t\tvalues[\"OLLAMA_API_BASE\"] = strings.TrimSpace(ollamaAPIBase)\n\t\tvalues[\"OLLAMA_MODEL\"] = strings.TrimSpace(ollamaModel)\n\t}\n\tif contains(methods, methodOllamaCloud) {\n\t\tvalues[\"OLLAMA_CLOUD_API_BASE\"] = strings.TrimSpace(ollamaCloudAPIBase)\n\t\tvalues[\"OLLAMA_CLOUD_API_KEY\"] = strings.TrimSpace(ollamaCloudAPIKey)\n\t\tvalues[\"OLLAMA_CLOUD_MODEL\"] = strings.TrimSpace(ollamaCloudModel)\n\t}\n\tif contains(methods, methodBedrockAPI) {\n\t\tvalues[\"AWS_ACCESS_KEY_ID\"] = strings.TrimSpace(awsAccessKeyID)\n\t\tvalues[\"AWS_SECRET_ACCESS_KEY\"] = strings.TrimSpace(awsSecretAccessKey)\n\t\t// LiteLLM's Bedrock provider reads AWS_REGION_NAME, not the\n\t\t// boto3-standard AWS_REGION. Set both for SDK compatibility.\n\t\tvalues[\"AWS_REGION_NAME\"] = strings.TrimSpace(awsRegion)\n\t\tvalues[\"AWS_REGION\"] = strings.TrimSpace(awsRegion)\n\t}\n\tif contains(methods, methodVertexAPI) {\n\t\tvalues[\"GOOGLE_APPLICATION_CREDENTIALS\"] = strings.TrimSpace(vertexCredsPath)\n\t\tvalues[\"VERTEXAI_PROJECT\"] = strings.TrimSpace(vertexProject)\n\t\tvalues[\"VERTEXAI_LOCATION\"] = strings.TrimSpace(vertexLocation)\n\t}\n\tif contains(methods, methodAzureAPI) {\n\t\tvalues[\"AZURE_API_KEY\"] = strings.TrimSpace(azureAPIKey)\n\t\tvalues[\"AZURE_API_BASE\"] = strings.TrimSpace(azureAPIBase)\n\t\tvalues[\"AZURE_API_VERSION\"] = strings.TrimSpace(azureAPIVersion)\n\t}\n\tif githubToken != \"\" {\n\t\t// LiteLLM's github/ provider reads GITHUB_API_KEY; the GitHub\n\t\t// CLI + most docs use GITHUB_TOKEN. Set both so either path\n\t\t// works without operator surprise.\n\t\tvalues[\"GITHUB_TOKEN\"] = strings.TrimSpace(githubToken)\n\t\tvalues[\"GITHUB_API_KEY\"] = strings.TrimSpace(githubToken)\n\t}\n\tif groqKey != \"\" {\n\t\tvalues[\"GROQ_API_KEY\"] = strings.TrimSpace(groqKey)\n\t}\n\tif togetherKey != \"\" {\n\t\tvalues[\"TOGETHER_API_KEY\"] = strings.TrimSpace(togetherKey)\n\t}\n\tif fireworksKey != \"\" {\n\t\tvalues[\"FIREWORKS_API_KEY\"] = strings.TrimSpace(fireworksKey)\n\t}\n\tif cohereKey != \"\" {\n\t\tvalues[\"COHERE_API_KEY\"] = strings.TrimSpace(cohereKey)\n\t}\n\tif moonshotKey != \"\" {\n\t\tvalues[\"MOONSHOT_API_KEY\"] = strings.TrimSpace(moonshotKey)\n\t}\n\tif zaiKey != \"\" {\n\t\tvalues[\"ZAI_API_KEY\"] = strings.TrimSpace(zaiKey)\n\t}\n\tif dashscopeKey != \"\" {\n\t\tvalues[\"DASHSCOPE_API_KEY\"] = strings.TrimSpace(dashscopeKey)\n\t}\n\tif contains(methods, methodLMStudioLocal) {\n\t\tvalues[\"LMSTUDIO_API_BASE\"] = strings.TrimSpace(lmStudioAPIBase)\n\t\tvalues[\"LMSTUDIO_MODEL\"] = strings.TrimSpace(lmStudioModel)\n\t\t// LM Studio accepts any string as the API key; set a sentinel\n\t\t// so LiteLLM's openai-compatible shim doesn't fail an empty\n\t\t// Authorization header.\n\t\tvalues[\"LMSTUDIO_API_KEY\"] = \"lm-studio\"\n\t}\n\tif contains(methods, methodCustomOpenAIAPI) {\n\t\tvalues[\"CUSTOM_OPENAI_API_BASE\"] = strings.TrimSpace(customOpenAIAPIBase)\n\t\tvalues[\"CUSTOM_OPENAI_API_KEY\"] = strings.TrimSpace(customOpenAIAPIKey)\n\t\tvalues[\"CUSTOM_OPENAI_MODEL\"] = strings.TrimSpace(customOpenAIModel)\n\t}\n\n\tif useLangSmith && langSmithKey != \"\" {\n\t\tvalues[\"LANGSMITH_TRACING\"] = \"true\"\n\t\tvalues[\"LANGSMITH_API_KEY\"] = langSmithKey\n\t\tvalues[\"LANGSMITH_PROJECT\"] = \"decepticon\"\n\t}\n\n\t// Anonymous usage telemetry consent. Only sends when a gateway endpoint is\n\t// configured (DECEPTICON_TELEMETRY_ENDPOINT, shipped in .env.example); an\n\t// unset endpoint keeps it dormant even when the user opted in.\n\tvalues[\"DECEPTICON_TELEMETRY\"] = telemetryChoice\n\n\tif err := config.WriteEnvFromEmbed(config.EnvPath(), values); err != nil {\n\t\treturn fmt.Errorf(\"write .env: %w\", err)\n\t}\n\n\t// Record the telemetry policy as acknowledged: this fresh install just\n\t// answered the same consent question the start-time re-consent migration\n\t// would ask, so it must never be re-prompted.\n\tif err := migrate.MarkAcked(config.DecepticonHome(), migrate.TelemetryPolicyID); err != nil {\n\t\tui.Warning(\"Could not record telemetry consent ack: \" + err.Error())\n\t}\n\n\t// Summary\n\tfmt.Println()\n\tfmt.Println(ui.Green.Render(\" ✓ Configuration saved\"))\n\tfmt.Println()\n\tfmt.Println(ui.Dim.Render(\" ┌──────────────────────────────────┐\"))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Methods \") + ui.Dim.Render(strings.Join(priority, \", \")))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Profile \") + ui.Dim.Render(profile))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Language \") + ui.Dim.Render(language))\n\tif useLangSmith {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" LangSmith \") + ui.Green.Render(\"enabled\"))\n\t}\n\tif telemetryChoice != \"off\" {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Telemetry \") + ui.Green.Render(telemetryChoice))\n\t}\n\tfmt.Println(ui.Dim.Render(\" │\"))\n\tfmt.Println(ui.Dim.Render(\" │ \") + ui.Dim.Render(config.EnvPath()))\n\tfmt.Println(ui.Dim.Render(\" └──────────────────────────────────┘\"))\n\tfmt.Println()\n\n\t// Install opscontrol as a managed service (ADR-0006). Idempotent\n\t// — re-running onboard re-templates the unit so a launcher\n\t// upgrade picks up the new ExecStart path. On hosts without\n\t// systemd-user / launchd this is a no-op announcement, and the\n\t// launcher's spawn fallback takes over at `decepticon start`.\n\tfmt.Println()\n\tif err := opscontrol.EnsureInstalled(); err != nil {\n\t\t// Non-fatal: onboarding can complete without managed-service\n\t\t// mode, the user will just stay on launcher-spawn fallback.\n\t\tui.Warning(\"opscontrol install skipped: \" + err.Error())\n\t}\n\tfmt.Println()\n\n\t// One-time GitHub star ask — the natural post-onboarding moment.\n\t// Suppressed on subsequent runs by the ack file at\n\t// $DECEPTICON_HOME/.starred, so a re-run of `decepticon onboard\n\t// --reset` does not re-prompt.\n\tstarprompt.PromptIfNotStarred()\n\n\tui.DimText(\" Run 'decepticon' to start the platform\")\n\treturn nil\n}\n\nfunc contains(haystack []string, needle string) bool {\n\treturn slices.Contains(haystack, needle)\n}\n\nfunc nonEmpty(s string) error {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn fmt.Errorf(\"value is required\")\n\t}\n\treturn nil\n}\n\n// optionalClaudeOAuthToken accepts an empty value (the user falls back to the\n// on-disk credentials file) but, when provided, requires the Claude OAuth\n// token shape (`sk-ant-oat01-…`) so a mistyped API key or stray paste is\n// caught at the prompt rather than failing silently at runtime.\nfunc optionalClaudeOAuthToken(s string) error {\n\tt := strings.TrimSpace(s)\n\tif t == \"\" {\n\t\treturn nil\n\t}\n\tif !strings.HasPrefix(t, \"sk-ant-oat01-\") {\n\t\treturn fmt.Errorf(\"expected a `claude setup-token` token starting with sk-ant-oat01- (leave blank to use the credentials file)\")\n\t}\n\treturn nil\n}\n\nfunc boolStr(b bool) string {\n\tif b {\n\t\treturn \"true\"\n\t}\n\treturn \"false\"\n}\n\n// buildOllamaModelField returns the OLLAMA_MODEL form field that\n// matches the host probe outcome: strict Select when tool-capable\n// models are pulled, otherwise a remediation Note (different message\n// for the reachable-but-no-tools case vs unreachable Ollama).\nfunc buildOllamaModelField(probe ollamaProbeResult, selected *string) huh.Field {\n\tif len(probe.ToolCapableModels) > 0 {\n\t\toptions := make([]huh.Option[string], 0, len(probe.ToolCapableModels))\n\t\tfor _, m := range probe.ToolCapableModels {\n\t\t\toptions = append(options, huh.NewOption(m, m))\n\t\t}\n\t\tif !slices.Contains(probe.ToolCapableModels, *selected) {\n\t\t\t*selected = probe.ToolCapableModels[0]\n\t\t}\n\t\treturn huh.NewSelect[string]().\n\t\t\tTitle(\"OLLAMA_MODEL\").\n\t\t\tDescription(\"Tool-capable models found on your host. Decepticon\\nagents always emit tool calls — these are the only\\nmodels the wizard will accept.\").\n\t\t\tOptions(options...).\n\t\t\tValue(selected)\n\t}\n\n\tif probe.Reachable {\n\t\treturn huh.NewNote().\n\t\t\tTitle(\"OLLAMA_MODEL — no tool-capable models found\").\n\t\t\tDescription(\"Ollama is reachable but none of your pulled models\\nadvertise the 'tools' capability. Decepticon agents\\nalways emit tool calls, so a model without tool\\nsupport cannot power them.\\n\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n\t}\n\n\treturn huh.NewNote().\n\t\tTitle(\"OLLAMA_MODEL — Ollama not reachable\").\n\t\tDescription(\"Could not reach Ollama at \" + defaultOllamaAPIBase + \".\\n\\nMost likely Ollama isn't running or is bound to\\n127.0.0.1 only (which the Decepticon container can't\\nsee). Launch it on all interfaces, pull a tool-capable\\nmodel, then re-run the wizard:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n}\n\n// ollamaUnusableError surfaces the post-form remediation when the user\n// picked Ollama but the probe found nothing usable. Two flavors so the\n// hint matches whichever in-form Note was shown.\nfunc ollamaUnusableError(probe ollamaProbeResult, baseURL string) error {\n\tif probe.Reachable {\n\t\treturn fmt.Errorf(\n\t\t\t\"Ollama selected but no tool-capable models found on the host.\\n\" +\n\t\t\t\t\"Decepticon agents always emit tool calls — pull a tool-capable\\n\" +\n\t\t\t\t\"model and verify it advertises tools, then re-run:\\n\\n\" +\n\t\t\t\t\" ollama pull qwen3-coder:30b\\n\" +\n\t\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\" +\n\t\t\t\t\" decepticon onboard --reset\")\n\t}\n\treturn fmt.Errorf(\n\t\t\"Ollama selected but the host probe could not reach %s.\\n\"+\n\t\t\t\"Make sure Ollama is running and bound to all interfaces (the\\n\"+\n\t\t\t\"default 127.0.0.1 binding is invisible to containers), then\\n\"+\n\t\t\t\"pull a tool-capable model and re-run:\\n\\n\"+\n\t\t\t\" OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\"+\n\t\t\t\" ollama pull qwen3-coder:30b\\n\"+\n\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\"+\n\t\t\t\" decepticon onboard --reset\",\n\t\tbaseURL)\n}\n"}, "592d84aa3ffc85706579556156e8583a206fad76": {"t": 1785755383.768119, "n": 46831, "h": "3e33420922c41b34", "c": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/huh/v2\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/cmd/opscontrol\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/migrate\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/platform\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/starprompt\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui\"\n\t\"github.com/spf13/cobra\"\n)\n\n// onboardOllamaProbeBudget bounds the wait for the background Ollama\n// discovery probe at form-construction time. Per-request timeout\n// (ollama_models.go) is much shorter; this is the worst-case across\n// all candidate URLs.\nconst onboardOllamaProbeBudget = 3 * time.Second\n\nvar resetFlag bool\n\nvar onboardCmd = &cobra.Command{\n\tUse: \"onboard\",\n\tShort: \"Configure Decepticon (auth methods, model profile, observability)\",\n\tRunE: runOnboard,\n}\n\nfunc init() {\n\tonboardCmd.Flags().BoolVar(&resetFlag, \"reset\", false, \"Reconfigure even if .env already exists\")\n\trootCmd.AddCommand(onboardCmd)\n}\n\n// claudeSetupTokenDocsURL is the official Claude Code page covering\n// `claude setup-token` (mint a long-lived 1-year OAuth token for headless use).\nconst claudeSetupTokenDocsURL = \"https://code.claude.com/docs/en/authentication\"\n\n// AuthMethod identifiers — must match decepticon/llm/models.py::AuthMethod.\nconst (\n\tmethodAnthropicOAuth = \"anthropic_oauth\"\n\tmethodAnthropicAPI = \"anthropic_api\"\n\tmethodOpenAIOAuth = \"openai_oauth\"\n\tmethodOpenAIAPI = \"openai_api\"\n\tmethodGoogleOAuth = \"google_oauth\"\n\tmethodGoogleAPI = \"google_api\"\n\tmethodMiniMaxAPI = \"minimax_api\"\n\tmethodDeepSeekAPI = \"deepseek_api\"\n\tmethodXAIAPI = \"xai_api\"\n\tmethodGrokOAuth = \"grok_oauth\"\n\tmethodMistralAPI = \"mistral_api\"\n\tmethodOpenRouterAPI = \"openrouter_api\"\n\tmethodNvidiaAPI = \"nvidia_api\"\n\tmethodCopilotOAuth = \"copilot_oauth\"\n\tmethodPerplexityOAuth = \"perplexity_oauth\"\n\tmethodOllamaLocal = \"ollama_local\"\n\tmethodOllamaCloud = \"ollama_cloud\"\n\t// Cloud gateways added in the OpenClaude provider migration.\n\tmethodBedrockAPI = \"bedrock_api\"\n\tmethodVertexAPI = \"vertex_api\"\n\tmethodAzureAPI = \"azure_api\"\n\tmethodGroqAPI = \"groq_api\"\n\tmethodTogetherAPI = \"together_api\"\n\tmethodFireworksAPI = \"fireworks_api\"\n\tmethodCohereAPI = \"cohere_api\"\n\tmethodMoonshotAPI = \"moonshot_api\"\n\tmethodZaiAPI = \"zai_api\"\n\tmethodDashscopeAPI = \"dashscope_api\"\n\tmethodGitHubModelsAPI = \"github_models_api\"\n\tmethodLMStudioLocal = \"lmstudio_local\"\n\tmethodCustomOpenAIAPI = \"custom_openai_api\"\n)\n\n// Defaults shown in form placeholders for the new providers.\nconst (\n\tdefaultLMStudioAPIBase = \"http://host.docker.internal:1234/v1\"\n\tdefaultLMStudioModel = \"qwen2.5-coder-7b-instruct\"\n\tdefaultAzureAPIVersion = \"2024-08-01-preview\"\n)\n\n// Default Ollama wiring shown to OSS users. `host.docker.internal`\n// is the universal answer regardless of where Ollama runs (macOS host,\n// WSL2 distro, native Linux): from inside Decepticon's containers it\n// resolves to the host network namespace via the\n// `extra_hosts: [host.docker.internal:host-gateway]` entry on the\n// litellm service in docker-compose.yml. `localhost` is never the\n// right answer here — that's the container itself.\n//\n// The host's Ollama must additionally be bound to 0.0.0.0 (the default\n// 127.0.0.1 binding only accepts host-side connections); the wizard\n// surfaces this requirement to the user.\n//\n// The default model is the smallest/fastest one most laptops can\n// actually run; users with a GPU will pick something like qwen3-coder:30b.\nconst (\n\tdefaultOllamaAPIBase = \"http://host.docker.internal:11434\"\n\tdefaultOllamaModel = \"llama3.2\"\n)\n\n// methodOrder is the priority order surfaced in the wizard. The\n// resulting DECEPTICON_AUTH_PRIORITY preserves this order, filtered\n// to the methods the user actually selected. OAuth precedes the\n// matching API on purpose: a subscription primary should fall back\n// to the paid API only when the subscription quota is exhausted.\n// Ollama sits last: cloud providers are usually preferred when both\n// are configured, but a user wanting to lead with local-only inference\n// can reorder the priority manually in .env.\nvar methodOrder = []string{\n\tmethodAnthropicOAuth,\n\tmethodAnthropicAPI,\n\tmethodOpenAIOAuth,\n\tmethodOpenAIAPI,\n\tmethodGoogleOAuth,\n\tmethodGoogleAPI,\n\tmethodMiniMaxAPI,\n\tmethodDeepSeekAPI,\n\tmethodXAIAPI,\n\tmethodGrokOAuth,\n\tmethodMistralAPI,\n\tmethodOpenRouterAPI,\n\tmethodNvidiaAPI,\n\tmethodCopilotOAuth,\n\tmethodPerplexityOAuth,\n\t// Cloud gateways — anthropic-via-cloud paths first, then\n\t// other multi-vendor hubs.\n\tmethodBedrockAPI,\n\tmethodVertexAPI,\n\tmethodAzureAPI,\n\tmethodGitHubModelsAPI,\n\tmethodGroqAPI,\n\tmethodTogetherAPI,\n\tmethodFireworksAPI,\n\tmethodCohereAPI,\n\tmethodMoonshotAPI,\n\tmethodZaiAPI,\n\tmethodDashscopeAPI,\n\t// Local last so cloud-preferred default still picks remote\n\t// providers as primary; users can re-order in .env.\n\tmethodLMStudioLocal,\n\tmethodOllamaLocal,\n\tmethodOllamaCloud,\n\tmethodCustomOpenAIAPI,\n}\n\n// systemCheckGroup renders the host-environment snapshot as the first\n// step of onboarding. It detects OS/architecture (so the user sees the\n// machine is recognized — be it Windows, macOS, a Linux pentest distro,\n// or a Raspberry Pi) and the Docker readiness state, with OS-specific\n// remediation when something is missing. It never blocks: credentials\n// can be configured before Docker is sorted out.\nfunc systemCheckGroup(sys platform.SystemInfo) *huh.Group {\n\tvar b strings.Builder\n\n\tosLine := sys.OSLabel() + \" (\" + sys.Arch + \")\"\n\tif sys.IsWSL {\n\t\tosLine += \" · WSL2\"\n\t}\n\tb.WriteString(\"OS \" + osLine + \"\\n\")\n\n\tdocker := \"not installed\"\n\tif sys.DockerInstalled {\n\t\tdocker = \"installed\"\n\t\tif sys.DockerRunning {\n\t\t\tdocker += \" · running\"\n\t\t} else {\n\t\t\tdocker += \" · stopped\"\n\t\t}\n\t\tif sys.ComposeAvailable {\n\t\t\tdocker += \" · compose v2\"\n\t\t}\n\t}\n\tb.WriteString(\"Docker \" + docker + \"\\n\\n\")\n\n\tif sys.Ready() {\n\t\tb.WriteString(\"This system is ready to run Decepticon.\")\n\t} else {\n\t\tb.WriteString(sys.DockerHint() + \"\\n\")\n\t\tb.WriteString(\"You can still configure credentials now and sort out Docker before launching.\")\n\t}\n\n\treturn huh.NewGroup(\n\t\thuh.NewNote().\n\t\t\tTitle(\"System Check\").\n\t\t\tDescription(b.String()),\n\t)\n}\n\nfunc runOnboard(cmd *cobra.Command, args []string) error {\n\tif config.EnvExists() && !resetFlag {\n\t\tui.Info(\".env already configured at \" + config.EnvPath())\n\t\tui.DimText(\"Run 'decepticon onboard --reset' to reconfigure\")\n\t\treturn nil\n\t}\n\n\t// Kick off Ollama discovery in the background so the network\n\t// round-trip overlaps with huh's startup work; the OLLAMA_MODEL\n\t// field type depends on the result (Select vs remediation Note).\n\tprobeCh := make(chan ollamaProbeResult, 1)\n\tgo func() {\n\t\tprobeCh <- probeOllamaForOnboard(defaultOllamaAPIBase)\n\t}()\n\n\tvar (\n\t\tmethods []string\n\t\tanthropicKey string\n\t\tclaudeOAuthToken string\n\t\topenaiKey string\n\t\tgeminiKey string\n\t\tminimaxKey string\n\t\tdeepseekKey string\n\t\txaiKey string\n\t\tmistralKey string\n\t\topenrouterKey string\n\t\tnvidiaKey string\n\t\tgeminiSessionCookies string\n\t\tcopilotRefreshToken string\n\t\tgrokSessionToken string\n\t\tperplexitySessionToken string\n\t\tollamaAPIBase = defaultOllamaAPIBase\n\t\tollamaModel = defaultOllamaModel\n\t\t// Cloud gateways\n\t\tawsAccessKeyID string\n\t\tawsSecretAccessKey string\n\t\tawsRegion = \"us-east-1\"\n\t\tvertexCredsPath string\n\t\tvertexProject string\n\t\tvertexLocation = \"us-central1\"\n\t\tazureAPIKey string\n\t\tazureAPIBase string\n\t\tazureAPIVersion = defaultAzureAPIVersion\n\t\tgroqKey string\n\t\ttogetherKey string\n\t\tfireworksKey string\n\t\tcohereKey string\n\t\tmoonshotKey string\n\t\tzaiKey string\n\t\tdashscopeKey string\n\t\tgithubToken string\n\t\t// Cloud Ollama\n\t\tollamaCloudAPIBase string\n\t\tollamaCloudAPIKey string\n\t\tollamaCloudModel string\n\t\t// LM Studio (local OpenAI-compatible)\n\t\tlmStudioAPIBase = defaultLMStudioAPIBase\n\t\tlmStudioModel = defaultLMStudioModel\n\t\t// Custom OpenAI-compatible endpoint\n\t\tcustomOpenAIAPIBase string\n\t\tcustomOpenAIAPIKey string\n\t\tcustomOpenAIModel string\n\t\tprofile string\n\t\tlanguage = \"en\"\n\t\tuseLangSmith bool\n\t\tlangSmithKey string\n\t\ttelemetryConsent = true // opt-out: default to sharing (masked)\n\t)\n\t// Block on the probe (zero-value result on timeout means\n\t// \"unreachable\" — drops through to the remediation Note).\n\t// time.NewTimer + Stop avoids the time.After timer leak when the\n\t// probe finishes first.\n\tprobeTimer := time.NewTimer(onboardOllamaProbeBudget)\n\tvar ollamaProbe ollamaProbeResult\n\tselect {\n\tcase ollamaProbe = <-probeCh:\n\t\tprobeTimer.Stop()\n\tcase <-probeTimer.C:\n\t}\n\tollamaModelField := buildOllamaModelField(ollamaProbe, &ollamaModel)\n\n\t// Probe the host environment so the wizard can confirm — up front —\n\t// that this machine (whatever OS/arch it is) can actually run the\n\t// Docker stack, and surface remediation before the user spends time\n\t// entering credentials.\n\tsysInfo := platform.Detect()\n\n\tform := huh.NewForm(\n\t\t// Step 0: system check.\n\t\tsystemCheckGroup(sysInfo),\n\n\t\t// Intro\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Decepticon Setup\").\n\t\t\t\tDescription(\"Configure auth methods, model profile, and\\nobservability.\\n\\nUse ↑↓ to navigate, space to toggle, Enter to confirm.\"),\n\t\t),\n\n\t\t// Step 1: Auth methods (multi-select)\n\t\thuh.NewGroup(\n\t\t\thuh.NewMultiSelect[string]().\n\t\t\t\tTitle(\"Auth Methods\").\n\t\t\t\tDescription(\"Press SPACE to toggle, ENTER to confirm.\\nUse ↑↓ to scroll, '/' to filter the list.\\nPick every credential you have — each is an\\nindependent fallback in priority order.\").\n\t\t\t\tFilterable(true).\n\t\t\t\tHeight(15).\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"Claude Code OAuth — Anthropic subscription (auth/*)\", methodAnthropicOAuth),\n\t\t\t\t\thuh.NewOption(\"Anthropic API Key — sk-ant-...\", methodAnthropicAPI),\n\t\t\t\t\thuh.NewOption(\"ChatGPT OAuth — ChatGPT Pro/Plus/Team subscription via `codex login` (auth/gpt-*)\", methodOpenAIOAuth),\n\t\t\t\t\thuh.NewOption(\"OpenAI API Key — sk-...\", methodOpenAIAPI),\n\t\t\t\t\thuh.NewOption(\"Google API Key — AIza... (Gemini)\", methodGoogleAPI),\n\t\t\t\t\thuh.NewOption(\"MiniMax API Key — eyJ...\", methodMiniMaxAPI),\n\t\t\t\t\thuh.NewOption(\"DeepSeek API Key — sk-...\", methodDeepSeekAPI),\n\t\t\t\t\thuh.NewOption(\"xAI API Key — xai-... (Grok)\", methodXAIAPI),\n\t\t\t\t\thuh.NewOption(\"Mistral API Key — (no fixed prefix)\", methodMistralAPI),\n\t\t\t\t\thuh.NewOption(\"OpenRouter API Key — sk-or-...\", methodOpenRouterAPI),\n\t\t\t\t\thuh.NewOption(\"Nvidia NIM API Key — nvapi-...\", methodNvidiaAPI),\n\t\t\t\t\thuh.NewOption(\"Gemini Advanced — Google One AI Premium subscription (gemini-sub/*)\", methodGoogleOAuth),\n\t\t\t\t\thuh.NewOption(\"SuperGrok — X Premium+ Grok subscription (grok-sub/*)\", methodGrokOAuth),\n\t\t\t\t\thuh.NewOption(\"GitHub Copilot Pro — Copilot subscription (copilot/*)\", methodCopilotOAuth),\n\t\t\t\t\thuh.NewOption(\"Perplexity Pro — Perplexity subscription (pplx-sub/*)\", methodPerplexityOAuth),\n\t\t\t\t\thuh.NewOption(\"AWS Bedrock — Anthropic models on AWS (bedrock/*)\", methodBedrockAPI),\n\t\t\t\t\thuh.NewOption(\"GCP Vertex AI — Anthropic + Gemini on GCP (vertex_ai/*)\", methodVertexAPI),\n\t\t\t\t\thuh.NewOption(\"Azure OpenAI — Azure-hosted GPT deployments (azure/*)\", methodAzureAPI),\n\t\t\t\t\thuh.NewOption(\"GitHub Models — GPT family via GitHub PAT (github/*)\", methodGitHubModelsAPI),\n\t\t\t\t\thuh.NewOption(\"Groq Cloud — LPU-accelerated Llama (groq/*)\", methodGroqAPI),\n\t\t\t\t\thuh.NewOption(\"Together AI — Llama / Mixtral hub (together_ai/*)\", methodTogetherAPI),\n\t\t\t\t\thuh.NewOption(\"Fireworks AI — Llama / Mixtral hub (fireworks_ai/*)\", methodFireworksAPI),\n\t\t\t\t\thuh.NewOption(\"Cohere Command — Command-A / Command-R (cohere/*)\", methodCohereAPI),\n\t\t\t\t\thuh.NewOption(\"Moonshot Kimi K2 — Kimi K2 (moonshot/*)\", methodMoonshotAPI),\n\t\t\t\t\thuh.NewOption(\"Z.ai GLM-4.5 — GLM-4.5 / GLM-4.5-Air (zai/*)\", methodZaiAPI),\n\t\t\t\t\thuh.NewOption(\"Alibaba DashScope — Qwen Max/Plus/Turbo (dashscope/*)\", methodDashscopeAPI),\n\t\t\t\t\thuh.NewOption(\"LM Studio (local) — local OpenAI-compatible server (lm_studio/*)\", methodLMStudioLocal),\n\t\t\t\t\thuh.NewOption(\"Local LLM (Ollama) — any pulled model, no API key\", methodOllamaLocal),\n\t\t\t\t\thuh.NewOption(\"Ollama Cloud — hosted Ollama (ollama_chat/* via cloud)\", methodOllamaCloud),\n\t\t\t\t\thuh.NewOption(\"Custom OpenAI Endpoint — bring-your-own OpenAI-compatible URL\", methodCustomOpenAIAPI),\n\t\t\t\t).\n\t\t\t\tValue(&methods).\n\t\t\t\tValidate(func(s []string) error {\n\t\t\t\t\tif len(s) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"select at least one credential\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"1 / 5 · Credentials\").\n\t\t\tDescription(\"Select all that apply\"),\n\n\t\t// Step 2-claude-oauth: Claude Code subscription long-lived token.\n\t\t// Run `claude setup-token` once (any machine, interactive browser\n\t\t// OAuth) → paste the 1-year token here. It is honored via the\n\t\t// ANTHROPIC_OAUTH_TOKEN env override, which carries expiresAt=0 so it\n\t\t// is never auto-refreshed — headless-safe for 24/7 / cloud, with no\n\t\t// live Claude Code session and no ~/.claude/.credentials.json mount.\n\t\t// Optional: leave blank to fall back to the rotating on-disk\n\t\t// credentials from an interactive `claude` login.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Claude Code Subscription — 1-year token\").\n\t\t\t\tDescription(\n\t\t\t\t\t\"Two steps:\\n\\n\"+\n\t\t\t\t\t\t\"1) Generate a 1-year token — run:\\n\"+\n\t\t\t\t\t\t\" claude setup-token\\n\"+\n\t\t\t\t\t\t\" (opens browser OAuth; needs a Pro / Max / Team /\\n\"+\n\t\t\t\t\t\t\" Enterprise plan). Docs / issue link:\\n\"+\n\t\t\t\t\t\t\" \"+claudeSetupTokenDocsURL+\"\\n\\n\"+\n\t\t\t\t\t\t\"2) Paste the sk-ant-oat01-… token it prints into the\\n\"+\n\t\t\t\t\t\t\" field below.\\n\\n\"+\n\t\t\t\t\t\t\"Headless-safe: never refreshed, no live Claude Code\\n\"+\n\t\t\t\t\t\t\"session. Leave blank to use a rotating\\n\"+\n\t\t\t\t\t\t\"~/.claude/.credentials.json from a `claude` login.\",\n\t\t\t\t),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"ANTHROPIC_OAUTH_TOKEN\").\n\t\t\t\tPlaceholder(\"sk-ant-oat01-... (leave blank to use credentials file)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&claudeOAuthToken).\n\t\t\t\tValidate(optionalClaudeOAuthToken),\n\t\t).Title(\"2 / 5 · Claude Code Subscription\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicOAuth) }),\n\n\t\t// Step 2a: Anthropic API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Anthropic API Key\").\n\t\t\t\tPlaceholder(\"sk-ant-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&anthropicKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Anthropic API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicAPI) }),\n\n\t\t// Step 2c: OpenAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenAI API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenAI API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIAPI) }),\n\n\t\t// Step 2c: Google API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Google (Gemini) API Key\").\n\t\t\t\tPlaceholder(\"AIza...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Google API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleAPI) }),\n\n\t\t// Step 2d: MiniMax API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MiniMax API Key\").\n\t\t\t\tPlaceholder(\"eyJ...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&minimaxKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · MiniMax API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMiniMaxAPI) }),\n\n\t\t// Step 2d-i: DeepSeek API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DeepSeek API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&deepseekKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DeepSeek API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDeepSeekAPI) }),\n\n\t\t// Step 2d-ii: xAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"xAI API Key\").\n\t\t\t\tPlaceholder(\"xai-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&xaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · xAI API (Grok)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodXAIAPI) }),\n\n\t\t// Step 2d-iii: Mistral API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Mistral API Key\").\n\t\t\t\tPlaceholder(\"paste your Mistral API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&mistralKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Mistral API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMistralAPI) }),\n\n\t\t// Step 2e: OpenRouter API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenRouter API Key\").\n\t\t\t\tPlaceholder(\"sk-or-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openrouterKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenRouter API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenRouterAPI) }),\n\n\t\t// Step 2f: Nvidia NIM API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Nvidia NIM API Key\").\n\t\t\t\tPlaceholder(\"nvapi-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&nvidiaKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Nvidia NIM API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodNvidiaAPI) }),\n\n\t\t// Step 2-oauth-i: Gemini Advanced subscription\n\t\t// Multi-cookie value (NID + Secure-1PSID + Secure-1PSIDTS, etc.)\n\t\t// joined with semicolons. Optional — power users can drop a\n\t\t// tokens.json under ~/.config/gemini/ instead and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Gemini Advanced Subscription\").\n\t\t\t\tDescription(\"Open gemini.google.com → DevTools → Application →\\nCookies → gemini.google.com. Copy the values of\\nNID, __Secure-1PSID, __Secure-1PSIDTS as a single\\nsemicolon-joined string. Or leave blank to use\\nGEMINI_ACCESS_TOKEN / ~/.config/gemini/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GEMINI_SESSION_COOKIES\").\n\t\t\t\tPlaceholder(\"NID=...; __Secure-1PSID=... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiSessionCookies),\n\t\t).Title(\"2 / 5 · Gemini Advanced\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleOAuth) }),\n\n\t\t// Step 2-oauth-ii: SuperGrok subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"SuperGrok Subscription\").\n\t\t\t\tDescription(\"Open grok.com → DevTools → Application →\\nCookies → grok.com → copy the value of\\n`auth_token`. Or leave blank to use\\nGROK_ACCESS_TOKEN / ~/.config/grok/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROK_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste auth_token cookie value (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&grokSessionToken),\n\t\t).Title(\"2 / 5 · SuperGrok\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGrokOAuth) }),\n\n\t\t// Step 2-oauth-iii: GitHub Copilot Pro subscription\n\t\t// Refresh token rotation, not a single session cookie. Devs can\n\t\t// extract one from VSCode's Copilot extension config or follow\n\t\t// gh-copilot-cli onboarding. Optional via tokens.json fallback.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GitHub Copilot Pro Subscription\").\n\t\t\t\tDescription(\"Provide a Copilot refresh token (long-lived, used\\nto rotate access tokens). Or leave blank to use\\nCOPILOT_ACCESS_TOKEN / ~/.config/copilot/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COPILOT_REFRESH_TOKEN\").\n\t\t\t\tPlaceholder(\"ghu_... or ghr_... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&copilotRefreshToken),\n\t\t).Title(\"2 / 5 · Copilot Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCopilotOAuth) }),\n\n\t\t// Step 2-oauth-iv: Perplexity Pro subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Perplexity Pro Subscription\").\n\t\t\t\tDescription(\"Open perplexity.ai → DevTools → Application →\\nCookies → perplexity.ai → copy the value of\\n`next-auth.session-token`. Or leave blank to use\\nPERPLEXITY_ACCESS_TOKEN / ~/.config/perplexity/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"PERPLEXITY_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste next-auth.session-token (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&perplexitySessionToken),\n\t\t).Title(\"2 / 5 · Perplexity Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodPerplexityOAuth) }),\n\n\t\t// Step 2g: Local Ollama. The OLLAMA_MODEL field is built from\n\t\t// the host probe (buildOllamaModelField): a strict Select when\n\t\t// tool-capable models are pulled, a remediation Note otherwise.\n\t\t// The post-form gate refuses to write .env in the Note cases.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Local Ollama\").\n\t\t\t\tDescription(\"Ollama must already be running on your host with\\nat least one tool-capable model pulled. Launch it\\nbound to all interfaces so the Decepticon container\\ncan reach it:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\\nThe default 127.0.0.1 binding only accepts host-side\\nconnections — containers won't see it.\\n\\nThe default URL `host.docker.internal:11434` works\\non macOS, Linux, and WSL2 (with or without Docker\\nDesktop). Only change it for remote / custom setups.\\nNote: the model list is probed against the default URL\\nat wizard start; if you customize OLLAMA_API_BASE,\\nfinish the wizard then re-run 'decepticon onboard\\n--reset' so the probe targets the new endpoint.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_API_BASE\").\n\t\t\t\tPlaceholder(defaultOllamaAPIBase).\n\t\t\t\tValue(&ollamaAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\tollamaModelField,\n\t\t).Title(\"2 / 5 · Local LLM (Ollama)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaLocal) }),\n\n\t\t// Step 2-cloud-i: AWS Bedrock — three-field group (key + secret + region)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"AWS Bedrock\").\n\t\t\t\tDescription(\"Bedrock uses AWS SigV4 — provide IAM access key,\\nsecret, and the region where Anthropic models are\\nenabled (us-east-1 or us-west-2 typically).\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_ACCESS_KEY_ID\").\n\t\t\t\tPlaceholder(\"AKIA...\").\n\t\t\t\tValue(&awsAccessKeyID).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_SECRET_ACCESS_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&awsSecretAccessKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_REGION\").\n\t\t\t\tPlaceholder(\"us-east-1\").\n\t\t\t\tValue(&awsRegion).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · AWS Bedrock\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodBedrockAPI) }),\n\n\t\t// Step 2-cloud-ii: GCP Vertex AI — service-account JSON path + project + region\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GCP Vertex AI\").\n\t\t\t\tDescription(\"Vertex AI uses a Google Cloud service-account JSON.\\nDownload one from IAM & Admin → Service Accounts and\\npaste the absolute file path. Project + region must\\nmatch where you enabled Anthropic + Gemini models.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GOOGLE_APPLICATION_CREDENTIALS\").\n\t\t\t\tPlaceholder(\"/abs/path/to/service-account.json\").\n\t\t\t\tValue(&vertexCredsPath).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"VERTEXAI_PROJECT\").\n\t\t\t\tPlaceholder(\"my-gcp-project-id\").\n\t\t\t\tValue(&vertexProject).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"VERTEXAI_LOCATION\").\n\t\t\t\tPlaceholder(\"us-central1\").\n\t\t\t\tValue(&vertexLocation).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · GCP Vertex AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodVertexAPI) }),\n\n\t\t// Step 2-cloud-iii: Azure OpenAI — key + endpoint + version\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Azure OpenAI Service\").\n\t\t\t\tDescription(\"Endpoint is your azure-openai resource URL,\\ne.g. https://my-resource.openai.azure.com.\\nThe deployment names map 1:1 to gpt-5.5/gpt-5.4/...\\nso the model matrix in DF resolves out of the box.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&azureAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_BASE\").\n\t\t\t\tPlaceholder(\"https://<resource>.openai.azure.com\").\n\t\t\t\tValue(&azureAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_VERSION\").\n\t\t\t\tPlaceholder(defaultAzureAPIVersion).\n\t\t\t\tValue(&azureAPIVersion).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Azure OpenAI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAzureAPI) }),\n\n\t\t// Step 2-cloud-iv: GitHub Models — PAT\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GITHUB_TOKEN\").\n\t\t\t\tDescription(\"Fine-grained PAT with the 'models' permission scope.\\nGenerate at github.com/settings/personal-access-tokens.\").\n\t\t\t\tPlaceholder(\"github_pat_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&githubToken).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · GitHub Models\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGitHubModelsAPI) }),\n\n\t\t// Step 2-cloud-v: Groq\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROQ_API_KEY\").\n\t\t\t\tPlaceholder(\"gsk_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&groqKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Groq Cloud\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGroqAPI) }),\n\n\t\t// Step 2-cloud-vi: Together AI\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"TOGETHER_API_KEY\").\n\t\t\t\tPlaceholder(\"paste your Together AI API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&togetherKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Together AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodTogetherAPI) }),\n\n\t\t// Step 2-cloud-vii: Fireworks AI\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"FIREWORKS_API_KEY\").\n\t\t\t\tPlaceholder(\"fw_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&fireworksKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Fireworks AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodFireworksAPI) }),\n\n\t\t// Step 2-cloud-viii: Cohere\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COHERE_API_KEY\").\n\t\t\t\tPlaceholder(\"paste your Cohere API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&cohereKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Cohere\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCohereAPI) }),\n\n\t\t// Step 2-cloud-ix: Moonshot Kimi K2\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MOONSHOT_API_KEY\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&moonshotKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Moonshot Kimi K2\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMoonshotAPI) }),\n\n\t\t// Step 2-cloud-x: Z.ai GLM-4.5\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"ZAI_API_KEY\").\n\t\t\t\tDescription(\"Z.ai is OpenAI-compatible — base URL is hard-wired to\\nhttps://api.z.ai/api/paas/v4. Get a key at z.ai/manage.\").\n\t\t\t\tPlaceholder(\"paste your Z.ai API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&zaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Z.ai GLM-4.5\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodZaiAPI) }),\n\n\t\t// Step 2-cloud-xi: Alibaba DashScope (Qwen)\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DASHSCOPE_API_KEY\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&dashscopeKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DashScope (Qwen)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDashscopeAPI) }),\n\n\t\t// Step 2-local-ii: LM Studio (local OpenAI-compatible)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"LM Studio\").\n\t\t\t\tDescription(\"LM Studio runs a local OpenAI-compatible server.\\nLaunch the LM Studio app and start the server\\n(Developer tab → Start). The default port is 1234.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LMSTUDIO_API_BASE\").\n\t\t\t\tPlaceholder(defaultLMStudioAPIBase).\n\t\t\t\tValue(&lmStudioAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LMSTUDIO_MODEL\").\n\t\t\t\tDescription(\"Model identifier as shown in LM Studio (e.g.\\nqwen2.5-coder-7b-instruct).\").\n\t\t\t\tPlaceholder(defaultLMStudioModel).\n\t\t\t\tValue(&lmStudioModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · LM Studio\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodLMStudioLocal) }),\n\n\t\t// Step 2-local-iii: Ollama Cloud (hosted)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Ollama Cloud\").\n\t\t\t\tDescription(\"Hosted Ollama — same /api/chat tool-calling endpoint\\nas local Ollama, just behind an API key.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_API_BASE\").\n\t\t\t\tPlaceholder(\"https://api.ollama.com/v1\").\n\t\t\t\tValue(&ollamaCloudAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&ollamaCloudAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_MODEL\").\n\t\t\t\tPlaceholder(\"llama3.3:70b\").\n\t\t\t\tValue(&ollamaCloudModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Ollama Cloud\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaCloud) }),\n\n\t\t// Step 2-custom: Custom OpenAI-compatible endpoint\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Custom OpenAI-compatible Endpoint\").\n\t\t\t\tDescription(\"Point at any OpenAI-compatible server (gateways,\\nself-hosted vLLM, internal LiteLLM, etc.). The model\\nname is sent verbatim to the upstream — match what\\nyour gateway exposes.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_API_BASE\").\n\t\t\t\tPlaceholder(\"https://gateway.example.com/v1\").\n\t\t\t\tValue(&customOpenAIAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&customOpenAIAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_MODEL\").\n\t\t\t\tPlaceholder(\"gpt-4o-mini\").\n\t\t\t\tValue(&customOpenAIModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Custom OpenAI Endpoint\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCustomOpenAIAPI) }),\n\n\t\t// Step 3: Model profile\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Model Profile\").\n\t\t\t\tDescription(\"eco per-agent tier (recommended)\\nmax every agent on HIGH (expensive)\\ntest every agent on LOW (development)\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"eco — per-agent tier (recommended)\", \"eco\"),\n\t\t\t\t\thuh.NewOption(\"max — every agent on HIGH (expensive)\", \"max\"),\n\t\t\t\t\thuh.NewOption(\"test — every agent on LOW (development)\", \"test\"),\n\t\t\t\t).\n\t\t\t\tValue(&profile),\n\t\t).Title(\"3 / 5 · Profile\"),\n\n\t\t// Step 4: Language\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Agent Language\").\n\t\t\t\tDescription(\"Language for all agent prose output (menus, questions,\\nsummaries, errors). Technical output stays in English.\\nCountry codes (dk, se, jp, cn) are auto-resolved.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"en — English (default)\", \"en\"),\n\t\t\t\t\thuh.NewOption(\"no — Norwegian\", \"no\"),\n\t\t\t\t\thuh.NewOption(\"da — Danish\", \"da\"),\n\t\t\t\t\thuh.NewOption(\"sv — Swedish\", \"sv\"),\n\t\t\t\t\thuh.NewOption(\"fi — Finnish\", \"fi\"),\n\t\t\t\t\thuh.NewOption(\"is — Icelandic\", \"is\"),\n\t\t\t\t\thuh.NewOption(\"ko — Korean\", \"ko\"),\n\t\t\t\t\thuh.NewOption(\"ja — Japanese\", \"ja\"),\n\t\t\t\t\thuh.NewOption(\"zh — Chinese\", \"zh\"),\n\t\t\t\t\thuh.NewOption(\"zh-tw — Traditional Chinese\", \"zh-tw\"),\n\t\t\t\t\thuh.NewOption(\"es — Spanish\", \"es\"),\n\t\t\t\t\thuh.NewOption(\"pt — Portuguese\", \"pt\"),\n\t\t\t\t\thuh.NewOption(\"pt-br — Brazilian Portuguese\", \"pt-br\"),\n\t\t\t\t\thuh.NewOption(\"de — German\", \"de\"),\n\t\t\t\t\thuh.NewOption(\"fr — French\", \"fr\"),\n\t\t\t\t\thuh.NewOption(\"nl — Dutch\", \"nl\"),\n\t\t\t\t\thuh.NewOption(\"it — Italian\", \"it\"),\n\t\t\t\t\thuh.NewOption(\"pl — Polish\", \"pl\"),\n\t\t\t\t\thuh.NewOption(\"cs — Czech\", \"cs\"),\n\t\t\t\t\thuh.NewOption(\"uk — Ukrainian\", \"uk\"),\n\t\t\t\t\thuh.NewOption(\"ro — Romanian\", \"ro\"),\n\t\t\t\t\thuh.NewOption(\"hr — Croatian\", \"hr\"),\n\t\t\t\t\thuh.NewOption(\"bg — Bulgarian\", \"bg\"),\n\t\t\t\t\thuh.NewOption(\"ru — Russian\", \"ru\"),\n\t\t\t\t\thuh.NewOption(\"el — Greek\", \"el\"),\n\t\t\t\t\thuh.NewOption(\"hu — Hungarian\", \"hu\"),\n\t\t\t\t\thuh.NewOption(\"tr — Turkish\", \"tr\"),\n\t\t\t\t\thuh.NewOption(\"ar — Arabic\", \"ar\"),\n\t\t\t\t\thuh.NewOption(\"fa — Persian\", \"fa\"),\n\t\t\t\t\thuh.NewOption(\"he — Hebrew\", \"he\"),\n\t\t\t\t\thuh.NewOption(\"hi — Hindi\", \"hi\"),\n\t\t\t\t\thuh.NewOption(\"th — Thai\", \"th\"),\n\t\t\t\t\thuh.NewOption(\"vi — Vietnamese\", \"vi\"),\n\t\t\t\t\thuh.NewOption(\"id — Indonesian\", \"id\"),\n\t\t\t\t\thuh.NewOption(\"ms — Malay\", \"ms\"),\n\t\t\t\t\thuh.NewOption(\"tl — Filipino\", \"tl\"),\n\t\t\t\t\thuh.NewOption(\"sw — Swahili\", \"sw\"),\n\t\t\t\t\thuh.NewOption(\"af — Afrikaans\", \"af\"),\n\t\t\t\t\thuh.NewOption(\"wenyan — 文言文 + English technical terms\", \"wenyan\"),\n\t\t\t\t).\n\t\t\t\tValue(&language),\n\t\t).Title(\"4 / 5 · Language\"),\n\n\t\t// Step 5a: LangSmith toggle\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Enable LangSmith?\").\n\t\t\t\tDescription(\"LLM observability and trace collection\").\n\t\t\t\tAffirmative(\"Yes\").\n\t\t\t\tNegative(\"No\").\n\t\t\t\tValue(&useLangSmith),\n\t\t).Title(\"5 / 5 · Observability\"),\n\n\t\t// Step 5b: LangSmith key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LangSmith API Key\").\n\t\t\t\tPlaceholder(\"lsv2_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&langSmithKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"5 / 5 · LangSmith\").\n\t\t\tWithHideFunc(func() bool { return !useLangSmith }),\n\n\t\t// Step 5c: usage telemetry consent (opt-out — default yes)\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Share anonymous + masked red-team telemetry?\").\n\t\t\t\tDescription(\n\t\t\t\t\t\"Decepticon is free/OSS — sharing usage helps fund and improve it,\\n\"+\n\t\t\t\t\t\t\"and contributes masked red-team reasoning to train future open\\n\"+\n\t\t\t\t\t\t\"offensive-security agents.\\n\\n\"+\n\t\t\t\t\t\t\" • anonymous structural stats (tools used, finding severity/CWE,\\n\"+\n\t\t\t\t\t\t\" kill-chain phase)\\n\"+\n\t\t\t\t\t\t\" • the agent's red-team REASONING, with target identifiers MASKED\\n\"+\n\t\t\t\t\t\t\" (10.0.0.5 -> <HOST_1>, acme.com -> <DOMAIN_1>)\\n\\n\"+\n\t\t\t\t\t\t\"Never sent: raw prompts, real target IPs/hosts, credentials. Your\\n\"+\n\t\t\t\t\t\t\"IP is dropped at the gateway. Change anytime: DECEPTICON_TELEMETRY=off\\n\"+\n\t\t\t\t\t\t\"(or basic) in .env, `decepticon-cli telemetry off`, or DO_NOT_TRACK=1.\",\n\t\t\t\t).\n\t\t\t\tAffirmative(\"Yes, share (recommended)\").\n\t\t\t\tNegative(\"No, keep it off\").\n\t\t\t\tValue(&telemetryConsent),\n\t\t).Title(\"5 / 5 · Usage telemetry\"),\n\t).WithTheme(huh.ThemeFunc(ui.DecepticonTheme))\n\n\tif err := form.Run(); err != nil {\n\t\treturn fmt.Errorf(\"setup cancelled: %w\", err)\n\t}\n\n\t// Opt-out telemetry: consent maps to the research tier (masked\n\t// reasoning corpus); declining writes an explicit off.\n\ttelemetryChoice := \"research\"\n\tif !telemetryConsent {\n\t\ttelemetryChoice = \"off\"\n\t}\n\n\t// Strict-mode gate: refuse to write .env when the user picked\n\t// Ollama but the host probe found no tool-capable model. The\n\t// in-form Note shows the same remediation; this is the boundary\n\t// guarantee that a broken setup never ships.\n\tif contains(methods, methodOllamaLocal) && len(ollamaProbe.ToolCapableModels) == 0 {\n\t\treturn ollamaUnusableError(ollamaProbe, ollamaAPIBase)\n\t}\n\n\t// huh.MultiSelect returns selected values in option order, not the\n\t// order the user toggled. Re-derive the priority by walking\n\t// methodOrder and keeping only what the user picked.\n\tpriority := make([]string, 0, len(methods))\n\tfor _, m := range methodOrder {\n\t\tif contains(methods, m) {\n\t\t\tpriority = append(priority, m)\n\t\t}\n\t}\n\n\tvalues := map[string]string{\n\t\t\"DECEPTICON_MODEL_PROFILE\": profile,\n\t\t\"DECEPTICON_LANGUAGE\": language,\n\t\t\"DECEPTICON_AUTH_PRIORITY\": strings.Join(priority, \",\"),\n\t\t\"DECEPTICON_AUTH_CLAUDE_CODE\": boolStr(contains(methods, methodAnthropicOAuth)),\n\t\t\"DECEPTICON_AUTH_CHATGPT\": boolStr(contains(methods, methodOpenAIOAuth)),\n\t\t\"DECEPTICON_AUTH_GEMINI\": boolStr(contains(methods, methodGoogleOAuth)),\n\t\t\"DECEPTICON_AUTH_GROK\": boolStr(contains(methods, methodGrokOAuth)),\n\t\t\"DECEPTICON_AUTH_COPILOT\": boolStr(contains(methods, methodCopilotOAuth)),\n\t\t\"DECEPTICON_AUTH_PERPLEXITY\": boolStr(contains(methods, methodPerplexityOAuth)),\n\t}\n\n\tif anthropicKey != \"\" {\n\t\tvalues[\"ANTHROPIC_API_KEY\"] = anthropicKey\n\t}\n\tif claudeOAuthToken != \"\" {\n\t\t// Long-lived `claude setup-token`. The claude_code handler reads this\n\t\t// as a synthetic credential (expiresAt=0 → never refreshed); no\n\t\t// credentials-file mount or live CC session needed.\n\t\tvalues[\"ANTHROPIC_OAUTH_TOKEN\"] = strings.TrimSpace(claudeOAuthToken)\n\t}\n\tif openaiKey != \"\" {\n\t\tvalues[\"OPENAI_API_KEY\"] = openaiKey\n\t}\n\tif geminiKey != \"\" {\n\t\tvalues[\"GEMINI_API_KEY\"] = geminiKey\n\t}\n\tif minimaxKey != \"\" {\n\t\tvalues[\"MINIMAX_API_KEY\"] = minimaxKey\n\t}\n\tif deepseekKey != \"\" {\n\t\tvalues[\"DEEPSEEK_API_KEY\"] = deepseekKey\n\t}\n\tif xaiKey != \"\" {\n\t\tvalues[\"XAI_API_KEY\"] = xaiKey\n\t}\n\tif mistralKey != \"\" {\n\t\tvalues[\"MISTRAL_API_KEY\"] = mistralKey\n\t}\n\tif openrouterKey != \"\" {\n\t\tvalues[\"OPENROUTER_API_KEY\"] = openrouterKey\n\t}\n\tif nvidiaKey != \"\" {\n\t\tvalues[\"NVIDIA_API_KEY\"] = nvidiaKey\n\t}\n\tif geminiSessionCookies != \"\" {\n\t\tvalues[\"GEMINI_SESSION_COOKIES\"] = geminiSessionCookies\n\t}\n\tif grokSessionToken != \"\" {\n\t\tvalues[\"GROK_SESSION_TOKEN\"] = grokSessionToken\n\t}\n\tif copilotRefreshToken != \"\" {\n\t\tvalues[\"COPILOT_REFRESH_TOKEN\"] = copilotRefreshToken\n\t}\n\tif perplexitySessionToken != \"\" {\n\t\tvalues[\"PERPLEXITY_SESSION_TOKEN\"] = perplexitySessionToken\n\t}\n\tif contains(methods, methodOllamaLocal) {\n\t\tvalues[\"OLLAMA_API_BASE\"] = strings.TrimSpace(ollamaAPIBase)\n\t\tvalues[\"OLLAMA_MODEL\"] = strings.TrimSpace(ollamaModel)\n\t}\n\tif contains(methods, methodOllamaCloud) {\n\t\tvalues[\"OLLAMA_CLOUD_API_BASE\"] = strings.TrimSpace(ollamaCloudAPIBase)\n\t\tvalues[\"OLLAMA_CLOUD_API_KEY\"] = strings.TrimSpace(ollamaCloudAPIKey)\n\t\tvalues[\"OLLAMA_CLOUD_MODEL\"] = strings.TrimSpace(ollamaCloudModel)\n\t}\n\tif contains(methods, methodBedrockAPI) {\n\t\tvalues[\"AWS_ACCESS_KEY_ID\"] = strings.TrimSpace(awsAccessKeyID)\n\t\tvalues[\"AWS_SECRET_ACCESS_KEY\"] = strings.TrimSpace(awsSecretAccessKey)\n\t\t// LiteLLM's Bedrock provider reads AWS_REGION_NAME, not the\n\t\t// boto3-standard AWS_REGION. Set both for SDK compatibility.\n\t\tvalues[\"AWS_REGION_NAME\"] = strings.TrimSpace(awsRegion)\n\t\tvalues[\"AWS_REGION\"] = strings.TrimSpace(awsRegion)\n\t}\n\tif contains(methods, methodVertexAPI) {\n\t\tvalues[\"GOOGLE_APPLICATION_CREDENTIALS\"] = strings.TrimSpace(vertexCredsPath)\n\t\tvalues[\"VERTEXAI_PROJECT\"] = strings.TrimSpace(vertexProject)\n\t\tvalues[\"VERTEXAI_LOCATION\"] = strings.TrimSpace(vertexLocation)\n\t}\n\tif contains(methods, methodAzureAPI) {\n\t\tvalues[\"AZURE_API_KEY\"] = strings.TrimSpace(azureAPIKey)\n\t\tvalues[\"AZURE_API_BASE\"] = strings.TrimSpace(azureAPIBase)\n\t\tvalues[\"AZURE_API_VERSION\"] = strings.TrimSpace(azureAPIVersion)\n\t}\n\tif githubToken != \"\" {\n\t\t// LiteLLM's github/ provider reads GITHUB_API_KEY; the GitHub\n\t\t// CLI + most docs use GITHUB_TOKEN. Set both so either path\n\t\t// works without operator surprise.\n\t\tvalues[\"GITHUB_TOKEN\"] = strings.TrimSpace(githubToken)\n\t\tvalues[\"GITHUB_API_KEY\"] = strings.TrimSpace(githubToken)\n\t}\n\tif groqKey != \"\" {\n\t\tvalues[\"GROQ_API_KEY\"] = strings.TrimSpace(groqKey)\n\t}\n\tif togetherKey != \"\" {\n\t\tvalues[\"TOGETHER_API_KEY\"] = strings.TrimSpace(togetherKey)\n\t}\n\tif fireworksKey != \"\" {\n\t\tvalues[\"FIREWORKS_API_KEY\"] = strings.TrimSpace(fireworksKey)\n\t}\n\tif cohereKey != \"\" {\n\t\tvalues[\"COHERE_API_KEY\"] = strings.TrimSpace(cohereKey)\n\t}\n\tif moonshotKey != \"\" {\n\t\tvalues[\"MOONSHOT_API_KEY\"] = strings.TrimSpace(moonshotKey)\n\t}\n\tif zaiKey != \"\" {\n\t\tvalues[\"ZAI_API_KEY\"] = strings.TrimSpace(zaiKey)\n\t}\n\tif dashscopeKey != \"\" {\n\t\tvalues[\"DASHSCOPE_API_KEY\"] = strings.TrimSpace(dashscopeKey)\n\t}\n\tif contains(methods, methodLMStudioLocal) {\n\t\tvalues[\"LMSTUDIO_API_BASE\"] = strings.TrimSpace(lmStudioAPIBase)\n\t\tvalues[\"LMSTUDIO_MODEL\"] = strings.TrimSpace(lmStudioModel)\n\t\t// LM Studio accepts any string as the API key; set a sentinel\n\t\t// so LiteLLM's openai-compatible shim doesn't fail an empty\n\t\t// Authorization header.\n\t\tvalues[\"LMSTUDIO_API_KEY\"] = \"lm-studio\"\n\t}\n\tif contains(methods, methodCustomOpenAIAPI) {\n\t\tvalues[\"CUSTOM_OPENAI_API_BASE\"] = strings.TrimSpace(customOpenAIAPIBase)\n\t\tvalues[\"CUSTOM_OPENAI_API_KEY\"] = strings.TrimSpace(customOpenAIAPIKey)\n\t\tvalues[\"CUSTOM_OPENAI_MODEL\"] = strings.TrimSpace(customOpenAIModel)\n\t}\n\n\tif useLangSmith && langSmithKey != \"\" {\n\t\tvalues[\"LANGSMITH_TRACING\"] = \"true\"\n\t\tvalues[\"LANGSMITH_API_KEY\"] = langSmithKey\n\t\tvalues[\"LANGSMITH_PROJECT\"] = \"decepticon\"\n\t}\n\n\t// Anonymous usage telemetry consent. Only sends when a gateway endpoint is\n\t// configured (DECEPTICON_TELEMETRY_ENDPOINT, shipped in .env.example); an\n\t// unset endpoint keeps it dormant even when the user opted in.\n\tvalues[\"DECEPTICON_TELEMETRY\"] = telemetryChoice\n\n\tif err := config.WriteEnvFromEmbed(config.EnvPath(), values); err != nil {\n\t\treturn fmt.Errorf(\"write .env: %w\", err)\n\t}\n\n\t// Record the telemetry policy as acknowledged: this fresh install just\n\t// answered the same consent question the start-time re-consent migration\n\t// would ask, so it must never be re-prompted.\n\tif err := migrate.MarkAcked(config.DecepticonHome(), migrate.TelemetryPolicyID); err != nil {\n\t\tui.Warning(\"Could not record telemetry consent ack: \" + err.Error())\n\t}\n\n\t// Summary\n\tfmt.Println()\n\tfmt.Println(ui.Green.Render(\" ✓ Configuration saved\"))\n\tfmt.Println()\n\tfmt.Println(ui.Dim.Render(\" ┌──────────────────────────────────┐\"))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Methods \") + ui.Dim.Render(strings.Join(priority, \", \")))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Profile \") + ui.Dim.Render(profile))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Language \") + ui.Dim.Render(language))\n\tif useLangSmith {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" LangSmith \") + ui.Green.Render(\"enabled\"))\n\t}\n\tif telemetryChoice != \"off\" {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Telemetry \") + ui.Green.Render(telemetryChoice))\n\t}\n\tfmt.Println(ui.Dim.Render(\" │\"))\n\tfmt.Println(ui.Dim.Render(\" │ \") + ui.Dim.Render(config.EnvPath()))\n\tfmt.Println(ui.Dim.Render(\" └──────────────────────────────────┘\"))\n\tfmt.Println()\n\n\t// Install opscontrol as a managed service (ADR-0006). Idempotent\n\t// — re-running onboard re-templates the unit so a launcher\n\t// upgrade picks up the new ExecStart path. On hosts without\n\t// systemd-user / launchd this is a no-op announcement, and the\n\t// launcher's spawn fallback takes over at `decepticon start`.\n\tfmt.Println()\n\tif err := opscontrol.EnsureInstalled(); err != nil {\n\t\t// Non-fatal: onboarding can complete without managed-service\n\t\t// mode, the user will just stay on launcher-spawn fallback.\n\t\tui.Warning(\"opscontrol install skipped: \" + err.Error())\n\t}\n\tfmt.Println()\n\n\t// One-time GitHub star ask — the natural post-onboarding moment.\n\t// Suppressed on subsequent runs by the ack file at\n\t// $DECEPTICON_HOME/.starred, so a re-run of `decepticon onboard\n\t// --reset` does not re-prompt.\n\tstarprompt.PromptIfNotStarred()\n\n\tui.DimText(\" Run 'decepticon' to start the platform\")\n\treturn nil\n}\n\nfunc contains(haystack []string, needle string) bool {\n\treturn slices.Contains(haystack, needle)\n}\n\nfunc nonEmpty(s string) error {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn fmt.Errorf(\"value is required\")\n\t}\n\treturn nil\n}\n\n// optionalClaudeOAuthToken accepts an empty value (the user falls back to the\n// on-disk credentials file) but, when provided, requires the Claude OAuth\n// token shape (`sk-ant-oat01-…`) so a mistyped API key or stray paste is\n// caught at the prompt rather than failing silently at runtime.\nfunc optionalClaudeOAuthToken(s string) error {\n\tt := strings.TrimSpace(s)\n\tif t == \"\" {\n\t\treturn nil\n\t}\n\tif !strings.HasPrefix(t, \"sk-ant-oat01-\") {\n\t\treturn fmt.Errorf(\"expected a `claude setup-token` token starting with sk-ant-oat01- (leave blank to use the credentials file)\")\n\t}\n\treturn nil\n}\n\nfunc boolStr(b bool) string {\n\tif b {\n\t\treturn \"true\"\n\t}\n\treturn \"false\"\n}\n\n// buildOllamaModelField returns the OLLAMA_MODEL form field that\n// matches the host probe outcome: strict Select when tool-capable\n// models are pulled, otherwise a remediation Note (different message\n// for the reachable-but-no-tools case vs unreachable Ollama).\nfunc buildOllamaModelField(probe ollamaProbeResult, selected *string) huh.Field {\n\tif len(probe.ToolCapableModels) > 0 {\n\t\toptions := make([]huh.Option[string], 0, len(probe.ToolCapableModels))\n\t\tfor _, m := range probe.ToolCapableModels {\n\t\t\toptions = append(options, huh.NewOption(m, m))\n\t\t}\n\t\tif !slices.Contains(probe.ToolCapableModels, *selected) {\n\t\t\t*selected = probe.ToolCapableModels[0]\n\t\t}\n\t\treturn huh.NewSelect[string]().\n\t\t\tTitle(\"OLLAMA_MODEL\").\n\t\t\tDescription(\"Tool-capable models found on your host. Decepticon\\nagents always emit tool calls — these are the only\\nmodels the wizard will accept.\").\n\t\t\tOptions(options...).\n\t\t\tValue(selected)\n\t}\n\n\tif probe.Reachable {\n\t\treturn huh.NewNote().\n\t\t\tTitle(\"OLLAMA_MODEL — no tool-capable models found\").\n\t\t\tDescription(\"Ollama is reachable but none of your pulled models\\nadvertise the 'tools' capability. Decepticon agents\\nalways emit tool calls, so a model without tool\\nsupport cannot power them.\\n\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n\t}\n\n\treturn huh.NewNote().\n\t\tTitle(\"OLLAMA_MODEL — Ollama not reachable\").\n\t\tDescription(\"Could not reach Ollama at \" + defaultOllamaAPIBase + \".\\n\\nMost likely Ollama isn't running or is bound to\\n127.0.0.1 only (which the Decepticon container can't\\nsee). Launch it on all interfaces, pull a tool-capable\\nmodel, then re-run the wizard:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n}\n\n// ollamaUnusableError surfaces the post-form remediation when the user\n// picked Ollama but the probe found nothing usable. Two flavors so the\n// hint matches whichever in-form Note was shown.\nfunc ollamaUnusableError(probe ollamaProbeResult, baseURL string) error {\n\tif probe.Reachable {\n\t\treturn fmt.Errorf(\n\t\t\t\"Ollama selected but no tool-capable models found on the host.\\n\" +\n\t\t\t\t\"Decepticon agents always emit tool calls — pull a tool-capable\\n\" +\n\t\t\t\t\"model and verify it advertises tools, then re-run:\\n\\n\" +\n\t\t\t\t\" ollama pull qwen3-coder:30b\\n\" +\n\t\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\" +\n\t\t\t\t\" decepticon onboard --reset\")\n\t}\n\treturn fmt.Errorf(\n\t\t\"Ollama selected but the host probe could not reach %s.\\n\"+\n\t\t\t\"Make sure Ollama is running and bound to all interfaces (the\\n\"+\n\t\t\t\"default 127.0.0.1 binding is invisible to containers), then\\n\"+\n\t\t\t\"pull a tool-capable model and re-run:\\n\\n\"+\n\t\t\t\" OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\"+\n\t\t\t\" ollama pull qwen3-coder:30b\\n\"+\n\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\"+\n\t\t\t\" decepticon onboard --reset\",\n\t\tbaseURL)\n}\n"}, "4c0a7467aac13244624cd658e781c3e627366da8": {"t": 1785755385.9854078, "n": 46341, "h": "93463eacd260b70c", "c": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/huh/v2\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/cmd/opscontrol\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/platform\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/starprompt\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui\"\n\t\"github.com/spf13/cobra\"\n)\n\n// onboardOllamaProbeBudget bounds the wait for the background Ollama\n// discovery probe at form-construction time. Per-request timeout\n// (ollama_models.go) is much shorter; this is the worst-case across\n// all candidate URLs.\nconst onboardOllamaProbeBudget = 3 * time.Second\n\nvar resetFlag bool\n\nvar onboardCmd = &cobra.Command{\n\tUse: \"onboard\",\n\tShort: \"Configure Decepticon (auth methods, model profile, observability)\",\n\tRunE: runOnboard,\n}\n\nfunc init() {\n\tonboardCmd.Flags().BoolVar(&resetFlag, \"reset\", false, \"Reconfigure even if .env already exists\")\n\trootCmd.AddCommand(onboardCmd)\n}\n\n// claudeSetupTokenDocsURL is the official Claude Code page covering\n// `claude setup-token` (mint a long-lived 1-year OAuth token for headless use).\nconst claudeSetupTokenDocsURL = \"https://code.claude.com/docs/en/authentication\"\n\n// AuthMethod identifiers — must match decepticon/llm/models.py::AuthMethod.\nconst (\n\tmethodAnthropicOAuth = \"anthropic_oauth\"\n\tmethodAnthropicAPI = \"anthropic_api\"\n\tmethodOpenAIOAuth = \"openai_oauth\"\n\tmethodOpenAIAPI = \"openai_api\"\n\tmethodGoogleOAuth = \"google_oauth\"\n\tmethodGoogleAPI = \"google_api\"\n\tmethodMiniMaxAPI = \"minimax_api\"\n\tmethodDeepSeekAPI = \"deepseek_api\"\n\tmethodXAIAPI = \"xai_api\"\n\tmethodGrokOAuth = \"grok_oauth\"\n\tmethodMistralAPI = \"mistral_api\"\n\tmethodOpenRouterAPI = \"openrouter_api\"\n\tmethodNvidiaAPI = \"nvidia_api\"\n\tmethodCopilotOAuth = \"copilot_oauth\"\n\tmethodPerplexityOAuth = \"perplexity_oauth\"\n\tmethodOllamaLocal = \"ollama_local\"\n\tmethodOllamaCloud = \"ollama_cloud\"\n\t// Cloud gateways added in the OpenClaude provider migration.\n\tmethodBedrockAPI = \"bedrock_api\"\n\tmethodVertexAPI = \"vertex_api\"\n\tmethodAzureAPI = \"azure_api\"\n\tmethodGroqAPI = \"groq_api\"\n\tmethodTogetherAPI = \"together_api\"\n\tmethodFireworksAPI = \"fireworks_api\"\n\tmethodCohereAPI = \"cohere_api\"\n\tmethodMoonshotAPI = \"moonshot_api\"\n\tmethodZaiAPI = \"zai_api\"\n\tmethodDashscopeAPI = \"dashscope_api\"\n\tmethodGitHubModelsAPI = \"github_models_api\"\n\tmethodLMStudioLocal = \"lmstudio_local\"\n\tmethodCustomOpenAIAPI = \"custom_openai_api\"\n)\n\n// Defaults shown in form placeholders for the new providers.\nconst (\n\tdefaultLMStudioAPIBase = \"http://host.docker.internal:1234/v1\"\n\tdefaultLMStudioModel = \"qwen2.5-coder-7b-instruct\"\n\tdefaultAzureAPIVersion = \"2024-08-01-preview\"\n)\n\n// Default Ollama wiring shown to OSS users. `host.docker.internal`\n// is the universal answer regardless of where Ollama runs (macOS host,\n// WSL2 distro, native Linux): from inside Decepticon's containers it\n// resolves to the host network namespace via the\n// `extra_hosts: [host.docker.internal:host-gateway]` entry on the\n// litellm service in docker-compose.yml. `localhost` is never the\n// right answer here — that's the container itself.\n//\n// The host's Ollama must additionally be bound to 0.0.0.0 (the default\n// 127.0.0.1 binding only accepts host-side connections); the wizard\n// surfaces this requirement to the user.\n//\n// The default model is the smallest/fastest one most laptops can\n// actually run; users with a GPU will pick something like qwen3-coder:30b.\nconst (\n\tdefaultOllamaAPIBase = \"http://host.docker.internal:11434\"\n\tdefaultOllamaModel = \"llama3.2\"\n)\n\n// methodOrder is the priority order surfaced in the wizard. The\n// resulting DECEPTICON_AUTH_PRIORITY preserves this order, filtered\n// to the methods the user actually selected. OAuth precedes the\n// matching API on purpose: a subscription primary should fall back\n// to the paid API only when the subscription quota is exhausted.\n// Ollama sits last: cloud providers are usually preferred when both\n// are configured, but a user wanting to lead with local-only inference\n// can reorder the priority manually in .env.\nvar methodOrder = []string{\n\tmethodAnthropicOAuth,\n\tmethodAnthropicAPI,\n\tmethodOpenAIOAuth,\n\tmethodOpenAIAPI,\n\tmethodGoogleOAuth,\n\tmethodGoogleAPI,\n\tmethodMiniMaxAPI,\n\tmethodDeepSeekAPI,\n\tmethodXAIAPI,\n\tmethodGrokOAuth,\n\tmethodMistralAPI,\n\tmethodOpenRouterAPI,\n\tmethodNvidiaAPI,\n\tmethodCopilotOAuth,\n\tmethodPerplexityOAuth,\n\t// Cloud gateways — anthropic-via-cloud paths first, then\n\t// other multi-vendor hubs.\n\tmethodBedrockAPI,\n\tmethodVertexAPI,\n\tmethodAzureAPI,\n\tmethodGitHubModelsAPI,\n\tmethodGroqAPI,\n\tmethodTogetherAPI,\n\tmethodFireworksAPI,\n\tmethodCohereAPI,\n\tmethodMoonshotAPI,\n\tmethodZaiAPI,\n\tmethodDashscopeAPI,\n\t// Local last so cloud-preferred default still picks remote\n\t// providers as primary; users can re-order in .env.\n\tmethodLMStudioLocal,\n\tmethodOllamaLocal,\n\tmethodOllamaCloud,\n\tmethodCustomOpenAIAPI,\n}\n\n// systemCheckGroup renders the host-environment snapshot as the first\n// step of onboarding. It detects OS/architecture (so the user sees the\n// machine is recognized — be it Windows, macOS, a Linux pentest distro,\n// or a Raspberry Pi) and the Docker readiness state, with OS-specific\n// remediation when something is missing. It never blocks: credentials\n// can be configured before Docker is sorted out.\nfunc systemCheckGroup(sys platform.SystemInfo) *huh.Group {\n\tvar b strings.Builder\n\n\tosLine := sys.OSLabel() + \" (\" + sys.Arch + \")\"\n\tif sys.IsWSL {\n\t\tosLine += \" · WSL2\"\n\t}\n\tb.WriteString(\"OS \" + osLine + \"\\n\")\n\n\tdocker := \"not installed\"\n\tif sys.DockerInstalled {\n\t\tdocker = \"installed\"\n\t\tif sys.DockerRunning {\n\t\t\tdocker += \" · running\"\n\t\t} else {\n\t\t\tdocker += \" · stopped\"\n\t\t}\n\t\tif sys.ComposeAvailable {\n\t\t\tdocker += \" · compose v2\"\n\t\t}\n\t}\n\tb.WriteString(\"Docker \" + docker + \"\\n\\n\")\n\n\tif sys.Ready() {\n\t\tb.WriteString(\"This system is ready to run Decepticon.\")\n\t} else {\n\t\tb.WriteString(sys.DockerHint() + \"\\n\")\n\t\tb.WriteString(\"You can still configure credentials now and sort out Docker before launching.\")\n\t}\n\n\treturn huh.NewGroup(\n\t\thuh.NewNote().\n\t\t\tTitle(\"System Check\").\n\t\t\tDescription(b.String()),\n\t)\n}\n\nfunc runOnboard(cmd *cobra.Command, args []string) error {\n\tif config.EnvExists() && !resetFlag {\n\t\tui.Info(\".env already configured at \" + config.EnvPath())\n\t\tui.DimText(\"Run 'decepticon onboard --reset' to reconfigure\")\n\t\treturn nil\n\t}\n\n\t// Kick off Ollama discovery in the background so the network\n\t// round-trip overlaps with huh's startup work; the OLLAMA_MODEL\n\t// field type depends on the result (Select vs remediation Note).\n\tprobeCh := make(chan ollamaProbeResult, 1)\n\tgo func() {\n\t\tprobeCh <- probeOllamaForOnboard(defaultOllamaAPIBase)\n\t}()\n\n\tvar (\n\t\tmethods []string\n\t\tanthropicKey string\n\t\tclaudeOAuthToken string\n\t\topenaiKey string\n\t\tgeminiKey string\n\t\tminimaxKey string\n\t\tdeepseekKey string\n\t\txaiKey string\n\t\tmistralKey string\n\t\topenrouterKey string\n\t\tnvidiaKey string\n\t\tgeminiSessionCookies string\n\t\tcopilotRefreshToken string\n\t\tgrokSessionToken string\n\t\tperplexitySessionToken string\n\t\tollamaAPIBase = defaultOllamaAPIBase\n\t\tollamaModel = defaultOllamaModel\n\t\t// Cloud gateways\n\t\tawsAccessKeyID string\n\t\tawsSecretAccessKey string\n\t\tawsRegion = \"us-east-1\"\n\t\tvertexCredsPath string\n\t\tvertexProject string\n\t\tvertexLocation = \"us-central1\"\n\t\tazureAPIKey string\n\t\tazureAPIBase string\n\t\tazureAPIVersion = defaultAzureAPIVersion\n\t\tgroqKey string\n\t\ttogetherKey string\n\t\tfireworksKey string\n\t\tcohereKey string\n\t\tmoonshotKey string\n\t\tzaiKey string\n\t\tdashscopeKey string\n\t\tgithubToken string\n\t\t// Cloud Ollama\n\t\tollamaCloudAPIBase string\n\t\tollamaCloudAPIKey string\n\t\tollamaCloudModel string\n\t\t// LM Studio (local OpenAI-compatible)\n\t\tlmStudioAPIBase = defaultLMStudioAPIBase\n\t\tlmStudioModel = defaultLMStudioModel\n\t\t// Custom OpenAI-compatible endpoint\n\t\tcustomOpenAIAPIBase string\n\t\tcustomOpenAIAPIKey string\n\t\tcustomOpenAIModel string\n\t\tprofile string\n\t\tlanguage = \"en\"\n\t\tuseLangSmith bool\n\t\tlangSmithKey string\n\t\ttelemetryChoice = \"off\"\n\t)\n\t// Block on the probe (zero-value result on timeout means\n\t// \"unreachable\" — drops through to the remediation Note).\n\t// time.NewTimer + Stop avoids the time.After timer leak when the\n\t// probe finishes first.\n\tprobeTimer := time.NewTimer(onboardOllamaProbeBudget)\n\tvar ollamaProbe ollamaProbeResult\n\tselect {\n\tcase ollamaProbe = <-probeCh:\n\t\tprobeTimer.Stop()\n\tcase <-probeTimer.C:\n\t}\n\tollamaModelField := buildOllamaModelField(ollamaProbe, &ollamaModel)\n\n\t// Probe the host environment so the wizard can confirm — up front —\n\t// that this machine (whatever OS/arch it is) can actually run the\n\t// Docker stack, and surface remediation before the user spends time\n\t// entering credentials.\n\tsysInfo := platform.Detect()\n\n\tform := huh.NewForm(\n\t\t// Step 0: system check.\n\t\tsystemCheckGroup(sysInfo),\n\n\t\t// Intro\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Decepticon Setup\").\n\t\t\t\tDescription(\"Configure auth methods, model profile, and\\nobservability.\\n\\nUse ↑↓ to navigate, space to toggle, Enter to confirm.\"),\n\t\t),\n\n\t\t// Step 1: Auth methods (multi-select)\n\t\thuh.NewGroup(\n\t\t\thuh.NewMultiSelect[string]().\n\t\t\t\tTitle(\"Auth Methods\").\n\t\t\t\tDescription(\"Press SPACE to toggle, ENTER to confirm.\\nUse ↑↓ to scroll, '/' to filter the list.\\nPick every credential you have — each is an\\nindependent fallback in priority order.\").\n\t\t\t\tFilterable(true).\n\t\t\t\tHeight(15).\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"Claude Code OAuth — Anthropic subscription (auth/*)\", methodAnthropicOAuth),\n\t\t\t\t\thuh.NewOption(\"Anthropic API Key — sk-ant-...\", methodAnthropicAPI),\n\t\t\t\t\thuh.NewOption(\"ChatGPT OAuth — ChatGPT Pro/Plus/Team subscription via `codex login` (auth/gpt-*)\", methodOpenAIOAuth),\n\t\t\t\t\thuh.NewOption(\"OpenAI API Key — sk-...\", methodOpenAIAPI),\n\t\t\t\t\thuh.NewOption(\"Google API Key — AIza... (Gemini)\", methodGoogleAPI),\n\t\t\t\t\thuh.NewOption(\"MiniMax API Key — eyJ...\", methodMiniMaxAPI),\n\t\t\t\t\thuh.NewOption(\"DeepSeek API Key — sk-...\", methodDeepSeekAPI),\n\t\t\t\t\thuh.NewOption(\"xAI API Key — xai-... (Grok)\", methodXAIAPI),\n\t\t\t\t\thuh.NewOption(\"Mistral API Key — (no fixed prefix)\", methodMistralAPI),\n\t\t\t\t\thuh.NewOption(\"OpenRouter API Key — sk-or-...\", methodOpenRouterAPI),\n\t\t\t\t\thuh.NewOption(\"Nvidia NIM API Key — nvapi-...\", methodNvidiaAPI),\n\t\t\t\t\thuh.NewOption(\"Gemini Advanced — Google One AI Premium subscription (gemini-sub/*)\", methodGoogleOAuth),\n\t\t\t\t\thuh.NewOption(\"SuperGrok — X Premium+ Grok subscription (grok-sub/*)\", methodGrokOAuth),\n\t\t\t\t\thuh.NewOption(\"GitHub Copilot Pro — Copilot subscription (copilot/*)\", methodCopilotOAuth),\n\t\t\t\t\thuh.NewOption(\"Perplexity Pro — Perplexity subscription (pplx-sub/*)\", methodPerplexityOAuth),\n\t\t\t\t\thuh.NewOption(\"AWS Bedrock — Anthropic models on AWS (bedrock/*)\", methodBedrockAPI),\n\t\t\t\t\thuh.NewOption(\"GCP Vertex AI — Anthropic + Gemini on GCP (vertex_ai/*)\", methodVertexAPI),\n\t\t\t\t\thuh.NewOption(\"Azure OpenAI — Azure-hosted GPT deployments (azure/*)\", methodAzureAPI),\n\t\t\t\t\thuh.NewOption(\"GitHub Models — GPT family via GitHub PAT (github/*)\", methodGitHubModelsAPI),\n\t\t\t\t\thuh.NewOption(\"Groq Cloud — LPU-accelerated Llama (groq/*)\", methodGroqAPI),\n\t\t\t\t\thuh.NewOption(\"Together AI — Llama / Mixtral hub (together_ai/*)\", methodTogetherAPI),\n\t\t\t\t\thuh.NewOption(\"Fireworks AI — Llama / Mixtral hub (fireworks_ai/*)\", methodFireworksAPI),\n\t\t\t\t\thuh.NewOption(\"Cohere Command — Command-A / Command-R (cohere/*)\", methodCohereAPI),\n\t\t\t\t\thuh.NewOption(\"Moonshot Kimi K2 — Kimi K2 (moonshot/*)\", methodMoonshotAPI),\n\t\t\t\t\thuh.NewOption(\"Z.ai GLM-4.5 — GLM-4.5 / GLM-4.5-Air (zai/*)\", methodZaiAPI),\n\t\t\t\t\thuh.NewOption(\"Alibaba DashScope — Qwen Max/Plus/Turbo (dashscope/*)\", methodDashscopeAPI),\n\t\t\t\t\thuh.NewOption(\"LM Studio (local) — local OpenAI-compatible server (lm_studio/*)\", methodLMStudioLocal),\n\t\t\t\t\thuh.NewOption(\"Local LLM (Ollama) — any pulled model, no API key\", methodOllamaLocal),\n\t\t\t\t\thuh.NewOption(\"Ollama Cloud — hosted Ollama (ollama_chat/* via cloud)\", methodOllamaCloud),\n\t\t\t\t\thuh.NewOption(\"Custom OpenAI Endpoint — bring-your-own OpenAI-compatible URL\", methodCustomOpenAIAPI),\n\t\t\t\t).\n\t\t\t\tValue(&methods).\n\t\t\t\tValidate(func(s []string) error {\n\t\t\t\t\tif len(s) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"select at least one credential\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"1 / 5 · Credentials\").\n\t\t\tDescription(\"Select all that apply\"),\n\n\t\t// Step 2-claude-oauth: Claude Code subscription long-lived token.\n\t\t// Run `claude setup-token` once (any machine, interactive browser\n\t\t// OAuth) → paste the 1-year token here. It is honored via the\n\t\t// ANTHROPIC_OAUTH_TOKEN env override, which carries expiresAt=0 so it\n\t\t// is never auto-refreshed — headless-safe for 24/7 / cloud, with no\n\t\t// live Claude Code session and no ~/.claude/.credentials.json mount.\n\t\t// Optional: leave blank to fall back to the rotating on-disk\n\t\t// credentials from an interactive `claude` login.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Claude Code Subscription — 1-year token\").\n\t\t\t\tDescription(\n\t\t\t\t\t\"Two steps:\\n\\n\"+\n\t\t\t\t\t\t\"1) Generate a 1-year token — run:\\n\"+\n\t\t\t\t\t\t\" claude setup-token\\n\"+\n\t\t\t\t\t\t\" (opens browser OAuth; needs a Pro / Max / Team /\\n\"+\n\t\t\t\t\t\t\" Enterprise plan). Docs / issue link:\\n\"+\n\t\t\t\t\t\t\" \"+claudeSetupTokenDocsURL+\"\\n\\n\"+\n\t\t\t\t\t\t\"2) Paste the sk-ant-oat01-… token it prints into the\\n\"+\n\t\t\t\t\t\t\" field below.\\n\\n\"+\n\t\t\t\t\t\t\"Headless-safe: never refreshed, no live Claude Code\\n\"+\n\t\t\t\t\t\t\"session. Leave blank to use a rotating\\n\"+\n\t\t\t\t\t\t\"~/.claude/.credentials.json from a `claude` login.\",\n\t\t\t\t),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"ANTHROPIC_OAUTH_TOKEN\").\n\t\t\t\tPlaceholder(\"sk-ant-oat01-... (leave blank to use credentials file)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&claudeOAuthToken).\n\t\t\t\tValidate(optionalClaudeOAuthToken),\n\t\t).Title(\"2 / 5 · Claude Code Subscription\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicOAuth) }),\n\n\t\t// Step 2a: Anthropic API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Anthropic API Key\").\n\t\t\t\tPlaceholder(\"sk-ant-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&anthropicKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Anthropic API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicAPI) }),\n\n\t\t// Step 2c: OpenAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenAI API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenAI API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIAPI) }),\n\n\t\t// Step 2c: Google API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Google (Gemini) API Key\").\n\t\t\t\tPlaceholder(\"AIza...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Google API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleAPI) }),\n\n\t\t// Step 2d: MiniMax API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MiniMax API Key\").\n\t\t\t\tPlaceholder(\"eyJ...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&minimaxKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · MiniMax API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMiniMaxAPI) }),\n\n\t\t// Step 2d-i: DeepSeek API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DeepSeek API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&deepseekKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DeepSeek API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDeepSeekAPI) }),\n\n\t\t// Step 2d-ii: xAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"xAI API Key\").\n\t\t\t\tPlaceholder(\"xai-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&xaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · xAI API (Grok)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodXAIAPI) }),\n\n\t\t// Step 2d-iii: Mistral API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Mistral API Key\").\n\t\t\t\tPlaceholder(\"paste your Mistral API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&mistralKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Mistral API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMistralAPI) }),\n\n\t\t// Step 2e: OpenRouter API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenRouter API Key\").\n\t\t\t\tPlaceholder(\"sk-or-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openrouterKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenRouter API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenRouterAPI) }),\n\n\t\t// Step 2f: Nvidia NIM API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Nvidia NIM API Key\").\n\t\t\t\tPlaceholder(\"nvapi-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&nvidiaKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Nvidia NIM API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodNvidiaAPI) }),\n\n\t\t// Step 2-oauth-i: Gemini Advanced subscription\n\t\t// Multi-cookie value (NID + Secure-1PSID + Secure-1PSIDTS, etc.)\n\t\t// joined with semicolons. Optional — power users can drop a\n\t\t// tokens.json under ~/.config/gemini/ instead and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Gemini Advanced Subscription\").\n\t\t\t\tDescription(\"Open gemini.google.com → DevTools → Application →\\nCookies → gemini.google.com. Copy the values of\\nNID, __Secure-1PSID, __Secure-1PSIDTS as a single\\nsemicolon-joined string. Or leave blank to use\\nGEMINI_ACCESS_TOKEN / ~/.config/gemini/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GEMINI_SESSION_COOKIES\").\n\t\t\t\tPlaceholder(\"NID=...; __Secure-1PSID=... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiSessionCookies),\n\t\t).Title(\"2 / 5 · Gemini Advanced\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleOAuth) }),\n\n\t\t// Step 2-oauth-ii: SuperGrok subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"SuperGrok Subscription\").\n\t\t\t\tDescription(\"Open grok.com → DevTools → Application →\\nCookies → grok.com → copy the value of\\n`auth_token`. Or leave blank to use\\nGROK_ACCESS_TOKEN / ~/.config/grok/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROK_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste auth_token cookie value (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&grokSessionToken),\n\t\t).Title(\"2 / 5 · SuperGrok\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGrokOAuth) }),\n\n\t\t// Step 2-oauth-iii: GitHub Copilot Pro subscription\n\t\t// Refresh token rotation, not a single session cookie. Devs can\n\t\t// extract one from VSCode's Copilot extension config or follow\n\t\t// gh-copilot-cli onboarding. Optional via tokens.json fallback.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GitHub Copilot Pro Subscription\").\n\t\t\t\tDescription(\"Provide a Copilot refresh token (long-lived, used\\nto rotate access tokens). Or leave blank to use\\nCOPILOT_ACCESS_TOKEN / ~/.config/copilot/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COPILOT_REFRESH_TOKEN\").\n\t\t\t\tPlaceholder(\"ghu_... or ghr_... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&copilotRefreshToken),\n\t\t).Title(\"2 / 5 · Copilot Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCopilotOAuth) }),\n\n\t\t// Step 2-oauth-iv: Perplexity Pro subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Perplexity Pro Subscription\").\n\t\t\t\tDescription(\"Open perplexity.ai → DevTools → Application →\\nCookies → perplexity.ai → copy the value of\\n`next-auth.session-token`. Or leave blank to use\\nPERPLEXITY_ACCESS_TOKEN / ~/.config/perplexity/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"PERPLEXITY_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste next-auth.session-token (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&perplexitySessionToken),\n\t\t).Title(\"2 / 5 · Perplexity Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodPerplexityOAuth) }),\n\n\t\t// Step 2g: Local Ollama. The OLLAMA_MODEL field is built from\n\t\t// the host probe (buildOllamaModelField): a strict Select when\n\t\t// tool-capable models are pulled, a remediation Note otherwise.\n\t\t// The post-form gate refuses to write .env in the Note cases.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Local Ollama\").\n\t\t\t\tDescription(\"Ollama must already be running on your host with\\nat least one tool-capable model pulled. Launch it\\nbound to all interfaces so the Decepticon container\\ncan reach it:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\\nThe default 127.0.0.1 binding only accepts host-side\\nconnections — containers won't see it.\\n\\nThe default URL `host.docker.internal:11434` works\\non macOS, Linux, and WSL2 (with or without Docker\\nDesktop). Only change it for remote / custom setups.\\nNote: the model list is probed against the default URL\\nat wizard start; if you customize OLLAMA_API_BASE,\\nfinish the wizard then re-run 'decepticon onboard\\n--reset' so the probe targets the new endpoint.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_API_BASE\").\n\t\t\t\tPlaceholder(defaultOllamaAPIBase).\n\t\t\t\tValue(&ollamaAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\tollamaModelField,\n\t\t).Title(\"2 / 5 · Local LLM (Ollama)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaLocal) }),\n\n\t\t// Step 2-cloud-i: AWS Bedrock — three-field group (key + secret + region)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"AWS Bedrock\").\n\t\t\t\tDescription(\"Bedrock uses AWS SigV4 — provide IAM access key,\\nsecret, and the region where Anthropic models are\\nenabled (us-east-1 or us-west-2 typically).\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_ACCESS_KEY_ID\").\n\t\t\t\tPlaceholder(\"AKIA...\").\n\t\t\t\tValue(&awsAccessKeyID).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_SECRET_ACCESS_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&awsSecretAccessKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_REGION\").\n\t\t\t\tPlaceholder(\"us-east-1\").\n\t\t\t\tValue(&awsRegion).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · AWS Bedrock\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodBedrockAPI) }),\n\n\t\t// Step 2-cloud-ii: GCP Vertex AI — service-account JSON path + project + region\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GCP Vertex AI\").\n\t\t\t\tDescription(\"Vertex AI uses a Google Cloud service-account JSON.\\nDownload one from IAM & Admin → Service Accounts and\\npaste the absolute file path. Project + region must\\nmatch where you enabled Anthropic + Gemini models.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GOOGLE_APPLICATION_CREDENTIALS\").\n\t\t\t\tPlaceholder(\"/abs/path/to/service-account.json\").\n\t\t\t\tValue(&vertexCredsPath).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"VERTEXAI_PROJECT\").\n\t\t\t\tPlaceholder(\"my-gcp-project-id\").\n\t\t\t\tValue(&vertexProject).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"VERTEXAI_LOCATION\").\n\t\t\t\tPlaceholder(\"us-central1\").\n\t\t\t\tValue(&vertexLocation).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · GCP Vertex AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodVertexAPI) }),\n\n\t\t// Step 2-cloud-iii: Azure OpenAI — key + endpoint + version\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Azure OpenAI Service\").\n\t\t\t\tDescription(\"Endpoint is your azure-openai resource URL,\\ne.g. https://my-resource.openai.azure.com.\\nThe deployment names map 1:1 to gpt-5.5/gpt-5.4/...\\nso the model matrix in DF resolves out of the box.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&azureAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_BASE\").\n\t\t\t\tPlaceholder(\"https://<resource>.openai.azure.com\").\n\t\t\t\tValue(&azureAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_VERSION\").\n\t\t\t\tPlaceholder(defaultAzureAPIVersion).\n\t\t\t\tValue(&azureAPIVersion).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Azure OpenAI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAzureAPI) }),\n\n\t\t// Step 2-cloud-iv: GitHub Models — PAT\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GITHUB_TOKEN\").\n\t\t\t\tDescription(\"Fine-grained PAT with the 'models' permission scope.\\nGenerate at github.com/settings/personal-access-tokens.\").\n\t\t\t\tPlaceholder(\"github_pat_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&githubToken).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · GitHub Models\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGitHubModelsAPI) }),\n\n\t\t// Step 2-cloud-v: Groq\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROQ_API_KEY\").\n\t\t\t\tPlaceholder(\"gsk_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&groqKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Groq Cloud\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGroqAPI) }),\n\n\t\t// Step 2-cloud-vi: Together AI\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"TOGETHER_API_KEY\").\n\t\t\t\tPlaceholder(\"paste your Together AI API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&togetherKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Together AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodTogetherAPI) }),\n\n\t\t// Step 2-cloud-vii: Fireworks AI\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"FIREWORKS_API_KEY\").\n\t\t\t\tPlaceholder(\"fw_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&fireworksKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Fireworks AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodFireworksAPI) }),\n\n\t\t// Step 2-cloud-viii: Cohere\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COHERE_API_KEY\").\n\t\t\t\tPlaceholder(\"paste your Cohere API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&cohereKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Cohere\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCohereAPI) }),\n\n\t\t// Step 2-cloud-ix: Moonshot Kimi K2\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MOONSHOT_API_KEY\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&moonshotKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Moonshot Kimi K2\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMoonshotAPI) }),\n\n\t\t// Step 2-cloud-x: Z.ai GLM-4.5\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"ZAI_API_KEY\").\n\t\t\t\tDescription(\"Z.ai is OpenAI-compatible — base URL is hard-wired to\\nhttps://api.z.ai/api/paas/v4. Get a key at z.ai/manage.\").\n\t\t\t\tPlaceholder(\"paste your Z.ai API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&zaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Z.ai GLM-4.5\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodZaiAPI) }),\n\n\t\t// Step 2-cloud-xi: Alibaba DashScope (Qwen)\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DASHSCOPE_API_KEY\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&dashscopeKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DashScope (Qwen)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDashscopeAPI) }),\n\n\t\t// Step 2-local-ii: LM Studio (local OpenAI-compatible)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"LM Studio\").\n\t\t\t\tDescription(\"LM Studio runs a local OpenAI-compatible server.\\nLaunch the LM Studio app and start the server\\n(Developer tab → Start). The default port is 1234.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LMSTUDIO_API_BASE\").\n\t\t\t\tPlaceholder(defaultLMStudioAPIBase).\n\t\t\t\tValue(&lmStudioAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LMSTUDIO_MODEL\").\n\t\t\t\tDescription(\"Model identifier as shown in LM Studio (e.g.\\nqwen2.5-coder-7b-instruct).\").\n\t\t\t\tPlaceholder(defaultLMStudioModel).\n\t\t\t\tValue(&lmStudioModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · LM Studio\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodLMStudioLocal) }),\n\n\t\t// Step 2-local-iii: Ollama Cloud (hosted)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Ollama Cloud\").\n\t\t\t\tDescription(\"Hosted Ollama — same /api/chat tool-calling endpoint\\nas local Ollama, just behind an API key.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_API_BASE\").\n\t\t\t\tPlaceholder(\"https://api.ollama.com/v1\").\n\t\t\t\tValue(&ollamaCloudAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&ollamaCloudAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_MODEL\").\n\t\t\t\tPlaceholder(\"llama3.3:70b\").\n\t\t\t\tValue(&ollamaCloudModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Ollama Cloud\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaCloud) }),\n\n\t\t// Step 2-custom: Custom OpenAI-compatible endpoint\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Custom OpenAI-compatible Endpoint\").\n\t\t\t\tDescription(\"Point at any OpenAI-compatible server (gateways,\\nself-hosted vLLM, internal LiteLLM, etc.). The model\\nname is sent verbatim to the upstream — match what\\nyour gateway exposes.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_API_BASE\").\n\t\t\t\tPlaceholder(\"https://gateway.example.com/v1\").\n\t\t\t\tValue(&customOpenAIAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&customOpenAIAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_MODEL\").\n\t\t\t\tPlaceholder(\"gpt-4o-mini\").\n\t\t\t\tValue(&customOpenAIModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Custom OpenAI Endpoint\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCustomOpenAIAPI) }),\n\n\t\t// Step 3: Model profile\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Model Profile\").\n\t\t\t\tDescription(\"eco per-agent tier (recommended)\\nmax every agent on HIGH (expensive)\\ntest every agent on LOW (development)\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"eco — per-agent tier (recommended)\", \"eco\"),\n\t\t\t\t\thuh.NewOption(\"max — every agent on HIGH (expensive)\", \"max\"),\n\t\t\t\t\thuh.NewOption(\"test — every agent on LOW (development)\", \"test\"),\n\t\t\t\t).\n\t\t\t\tValue(&profile),\n\t\t).Title(\"3 / 5 · Profile\"),\n\n\t\t// Step 4: Language\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Agent Language\").\n\t\t\t\tDescription(\"Language for all agent prose output (menus, questions,\\nsummaries, errors). Technical output stays in English.\\nCountry codes (dk, se, jp, cn) are auto-resolved.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"en — English (default)\", \"en\"),\n\t\t\t\t\thuh.NewOption(\"no — Norwegian\", \"no\"),\n\t\t\t\t\thuh.NewOption(\"da — Danish\", \"da\"),\n\t\t\t\t\thuh.NewOption(\"sv — Swedish\", \"sv\"),\n\t\t\t\t\thuh.NewOption(\"fi — Finnish\", \"fi\"),\n\t\t\t\t\thuh.NewOption(\"is — Icelandic\", \"is\"),\n\t\t\t\t\thuh.NewOption(\"ko — Korean\", \"ko\"),\n\t\t\t\t\thuh.NewOption(\"ja — Japanese\", \"ja\"),\n\t\t\t\t\thuh.NewOption(\"zh — Chinese\", \"zh\"),\n\t\t\t\t\thuh.NewOption(\"zh-tw — Traditional Chinese\", \"zh-tw\"),\n\t\t\t\t\thuh.NewOption(\"es — Spanish\", \"es\"),\n\t\t\t\t\thuh.NewOption(\"pt — Portuguese\", \"pt\"),\n\t\t\t\t\thuh.NewOption(\"pt-br — Brazilian Portuguese\", \"pt-br\"),\n\t\t\t\t\thuh.NewOption(\"de — German\", \"de\"),\n\t\t\t\t\thuh.NewOption(\"fr — French\", \"fr\"),\n\t\t\t\t\thuh.NewOption(\"nl — Dutch\", \"nl\"),\n\t\t\t\t\thuh.NewOption(\"it — Italian\", \"it\"),\n\t\t\t\t\thuh.NewOption(\"pl — Polish\", \"pl\"),\n\t\t\t\t\thuh.NewOption(\"cs — Czech\", \"cs\"),\n\t\t\t\t\thuh.NewOption(\"uk — Ukrainian\", \"uk\"),\n\t\t\t\t\thuh.NewOption(\"ro — Romanian\", \"ro\"),\n\t\t\t\t\thuh.NewOption(\"hr — Croatian\", \"hr\"),\n\t\t\t\t\thuh.NewOption(\"bg — Bulgarian\", \"bg\"),\n\t\t\t\t\thuh.NewOption(\"ru — Russian\", \"ru\"),\n\t\t\t\t\thuh.NewOption(\"el — Greek\", \"el\"),\n\t\t\t\t\thuh.NewOption(\"hu — Hungarian\", \"hu\"),\n\t\t\t\t\thuh.NewOption(\"tr — Turkish\", \"tr\"),\n\t\t\t\t\thuh.NewOption(\"ar — Arabic\", \"ar\"),\n\t\t\t\t\thuh.NewOption(\"fa — Persian\", \"fa\"),\n\t\t\t\t\thuh.NewOption(\"he — Hebrew\", \"he\"),\n\t\t\t\t\thuh.NewOption(\"hi — Hindi\", \"hi\"),\n\t\t\t\t\thuh.NewOption(\"th — Thai\", \"th\"),\n\t\t\t\t\thuh.NewOption(\"vi — Vietnamese\", \"vi\"),\n\t\t\t\t\thuh.NewOption(\"id — Indonesian\", \"id\"),\n\t\t\t\t\thuh.NewOption(\"ms — Malay\", \"ms\"),\n\t\t\t\t\thuh.NewOption(\"tl — Filipino\", \"tl\"),\n\t\t\t\t\thuh.NewOption(\"sw — Swahili\", \"sw\"),\n\t\t\t\t\thuh.NewOption(\"af — Afrikaans\", \"af\"),\n\t\t\t\t\thuh.NewOption(\"wenyan — 文言文 + English technical terms\", \"wenyan\"),\n\t\t\t\t).\n\t\t\t\tValue(&language),\n\t\t).Title(\"4 / 5 · Language\"),\n\n\t\t// Step 5a: LangSmith toggle\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Enable LangSmith?\").\n\t\t\t\tDescription(\"LLM observability and trace collection\").\n\t\t\t\tAffirmative(\"Yes\").\n\t\t\t\tNegative(\"No\").\n\t\t\t\tValue(&useLangSmith),\n\t\t).Title(\"5 / 5 · Observability\"),\n\n\t\t// Step 5b: LangSmith key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LangSmith API Key\").\n\t\t\t\tPlaceholder(\"lsv2_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&langSmithKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"5 / 5 · LangSmith\").\n\t\t\tWithHideFunc(func() bool { return !useLangSmith }),\n\n\t\t// Step 5c: anonymous usage telemetry (opt-in)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Share anonymous usage telemetry? (optional)\").\n\t\t\t\tDescription(\n\t\t\t\t\t\"Help improve Decepticon. Opt-in, and your IP is never stored.\\n\\n\"+\n\t\t\t\t\t\t\" basic anonymous stats only (tools used, finding severity/CWE,\\n\"+\n\t\t\t\t\t\t\" kill-chain phase) — no prompts, no targets.\\n\"+\n\t\t\t\t\t\t\" research basic + the red-team REASONING (the agent's tactics and\\n\"+\n\t\t\t\t\t\t\" rationale), captured to help train future autonomous\\n\"+\n\t\t\t\t\t\t\" red-team agents. Target identifiers are MASKED\\n\"+\n\t\t\t\t\t\t\" (10.0.0.5 -> <HOST_1>); real targets/creds never leave.\\n\\n\"+\n\t\t\t\t\t\t\"Never sent at any tier: raw prompts, target IPs/hosts, credentials.\\n\"+\n\t\t\t\t\t\t\"Change anytime: `decepticon-cli telemetry off`, or DO_NOT_TRACK=1.\",\n\t\t\t\t),\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Usage telemetry\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"No — share nothing (default)\", \"off\"),\n\t\t\t\t\thuh.NewOption(\"Basic — anonymous structural stats only\", \"basic\"),\n\t\t\t\t\thuh.NewOption(\"Research — basic + masked red-team reasoning\", \"research\"),\n\t\t\t\t).\n\t\t\t\tValue(&telemetryChoice),\n\t\t).Title(\"5 / 5 · Usage telemetry\"),\n\t).WithTheme(huh.ThemeFunc(ui.DecepticonTheme))\n\n\tif err := form.Run(); err != nil {\n\t\treturn fmt.Errorf(\"setup cancelled: %w\", err)\n\t}\n\n\t// Strict-mode gate: refuse to write .env when the user picked\n\t// Ollama but the host probe found no tool-capable model. The\n\t// in-form Note shows the same remediation; this is the boundary\n\t// guarantee that a broken setup never ships.\n\tif contains(methods, methodOllamaLocal) && len(ollamaProbe.ToolCapableModels) == 0 {\n\t\treturn ollamaUnusableError(ollamaProbe, ollamaAPIBase)\n\t}\n\n\t// huh.MultiSelect returns selected values in option order, not the\n\t// order the user toggled. Re-derive the priority by walking\n\t// methodOrder and keeping only what the user picked.\n\tpriority := make([]string, 0, len(methods))\n\tfor _, m := range methodOrder {\n\t\tif contains(methods, m) {\n\t\t\tpriority = append(priority, m)\n\t\t}\n\t}\n\n\tvalues := map[string]string{\n\t\t\"DECEPTICON_MODEL_PROFILE\": profile,\n\t\t\"DECEPTICON_LANGUAGE\": language,\n\t\t\"DECEPTICON_AUTH_PRIORITY\": strings.Join(priority, \",\"),\n\t\t\"DECEPTICON_AUTH_CLAUDE_CODE\": boolStr(contains(methods, methodAnthropicOAuth)),\n\t\t\"DECEPTICON_AUTH_CHATGPT\": boolStr(contains(methods, methodOpenAIOAuth)),\n\t\t\"DECEPTICON_AUTH_GEMINI\": boolStr(contains(methods, methodGoogleOAuth)),\n\t\t\"DECEPTICON_AUTH_GROK\": boolStr(contains(methods, methodGrokOAuth)),\n\t\t\"DECEPTICON_AUTH_COPILOT\": boolStr(contains(methods, methodCopilotOAuth)),\n\t\t\"DECEPTICON_AUTH_PERPLEXITY\": boolStr(contains(methods, methodPerplexityOAuth)),\n\t}\n\n\tif anthropicKey != \"\" {\n\t\tvalues[\"ANTHROPIC_API_KEY\"] = anthropicKey\n\t}\n\tif claudeOAuthToken != \"\" {\n\t\t// Long-lived `claude setup-token`. The claude_code handler reads this\n\t\t// as a synthetic credential (expiresAt=0 → never refreshed); no\n\t\t// credentials-file mount or live CC session needed.\n\t\tvalues[\"ANTHROPIC_OAUTH_TOKEN\"] = strings.TrimSpace(claudeOAuthToken)\n\t}\n\tif openaiKey != \"\" {\n\t\tvalues[\"OPENAI_API_KEY\"] = openaiKey\n\t}\n\tif geminiKey != \"\" {\n\t\tvalues[\"GEMINI_API_KEY\"] = geminiKey\n\t}\n\tif minimaxKey != \"\" {\n\t\tvalues[\"MINIMAX_API_KEY\"] = minimaxKey\n\t}\n\tif deepseekKey != \"\" {\n\t\tvalues[\"DEEPSEEK_API_KEY\"] = deepseekKey\n\t}\n\tif xaiKey != \"\" {\n\t\tvalues[\"XAI_API_KEY\"] = xaiKey\n\t}\n\tif mistralKey != \"\" {\n\t\tvalues[\"MISTRAL_API_KEY\"] = mistralKey\n\t}\n\tif openrouterKey != \"\" {\n\t\tvalues[\"OPENROUTER_API_KEY\"] = openrouterKey\n\t}\n\tif nvidiaKey != \"\" {\n\t\tvalues[\"NVIDIA_API_KEY\"] = nvidiaKey\n\t}\n\tif geminiSessionCookies != \"\" {\n\t\tvalues[\"GEMINI_SESSION_COOKIES\"] = geminiSessionCookies\n\t}\n\tif grokSessionToken != \"\" {\n\t\tvalues[\"GROK_SESSION_TOKEN\"] = grokSessionToken\n\t}\n\tif copilotRefreshToken != \"\" {\n\t\tvalues[\"COPILOT_REFRESH_TOKEN\"] = copilotRefreshToken\n\t}\n\tif perplexitySessionToken != \"\" {\n\t\tvalues[\"PERPLEXITY_SESSION_TOKEN\"] = perplexitySessionToken\n\t}\n\tif contains(methods, methodOllamaLocal) {\n\t\tvalues[\"OLLAMA_API_BASE\"] = strings.TrimSpace(ollamaAPIBase)\n\t\tvalues[\"OLLAMA_MODEL\"] = strings.TrimSpace(ollamaModel)\n\t}\n\tif contains(methods, methodOllamaCloud) {\n\t\tvalues[\"OLLAMA_CLOUD_API_BASE\"] = strings.TrimSpace(ollamaCloudAPIBase)\n\t\tvalues[\"OLLAMA_CLOUD_API_KEY\"] = strings.TrimSpace(ollamaCloudAPIKey)\n\t\tvalues[\"OLLAMA_CLOUD_MODEL\"] = strings.TrimSpace(ollamaCloudModel)\n\t}\n\tif contains(methods, methodBedrockAPI) {\n\t\tvalues[\"AWS_ACCESS_KEY_ID\"] = strings.TrimSpace(awsAccessKeyID)\n\t\tvalues[\"AWS_SECRET_ACCESS_KEY\"] = strings.TrimSpace(awsSecretAccessKey)\n\t\t// LiteLLM's Bedrock provider reads AWS_REGION_NAME, not the\n\t\t// boto3-standard AWS_REGION. Set both for SDK compatibility.\n\t\tvalues[\"AWS_REGION_NAME\"] = strings.TrimSpace(awsRegion)\n\t\tvalues[\"AWS_REGION\"] = strings.TrimSpace(awsRegion)\n\t}\n\tif contains(methods, methodVertexAPI) {\n\t\tvalues[\"GOOGLE_APPLICATION_CREDENTIALS\"] = strings.TrimSpace(vertexCredsPath)\n\t\tvalues[\"VERTEXAI_PROJECT\"] = strings.TrimSpace(vertexProject)\n\t\tvalues[\"VERTEXAI_LOCATION\"] = strings.TrimSpace(vertexLocation)\n\t}\n\tif contains(methods, methodAzureAPI) {\n\t\tvalues[\"AZURE_API_KEY\"] = strings.TrimSpace(azureAPIKey)\n\t\tvalues[\"AZURE_API_BASE\"] = strings.TrimSpace(azureAPIBase)\n\t\tvalues[\"AZURE_API_VERSION\"] = strings.TrimSpace(azureAPIVersion)\n\t}\n\tif githubToken != \"\" {\n\t\t// LiteLLM's github/ provider reads GITHUB_API_KEY; the GitHub\n\t\t// CLI + most docs use GITHUB_TOKEN. Set both so either path\n\t\t// works without operator surprise.\n\t\tvalues[\"GITHUB_TOKEN\"] = strings.TrimSpace(githubToken)\n\t\tvalues[\"GITHUB_API_KEY\"] = strings.TrimSpace(githubToken)\n\t}\n\tif groqKey != \"\" {\n\t\tvalues[\"GROQ_API_KEY\"] = strings.TrimSpace(groqKey)\n\t}\n\tif togetherKey != \"\" {\n\t\tvalues[\"TOGETHER_API_KEY\"] = strings.TrimSpace(togetherKey)\n\t}\n\tif fireworksKey != \"\" {\n\t\tvalues[\"FIREWORKS_API_KEY\"] = strings.TrimSpace(fireworksKey)\n\t}\n\tif cohereKey != \"\" {\n\t\tvalues[\"COHERE_API_KEY\"] = strings.TrimSpace(cohereKey)\n\t}\n\tif moonshotKey != \"\" {\n\t\tvalues[\"MOONSHOT_API_KEY\"] = strings.TrimSpace(moonshotKey)\n\t}\n\tif zaiKey != \"\" {\n\t\tvalues[\"ZAI_API_KEY\"] = strings.TrimSpace(zaiKey)\n\t}\n\tif dashscopeKey != \"\" {\n\t\tvalues[\"DASHSCOPE_API_KEY\"] = strings.TrimSpace(dashscopeKey)\n\t}\n\tif contains(methods, methodLMStudioLocal) {\n\t\tvalues[\"LMSTUDIO_API_BASE\"] = strings.TrimSpace(lmStudioAPIBase)\n\t\tvalues[\"LMSTUDIO_MODEL\"] = strings.TrimSpace(lmStudioModel)\n\t\t// LM Studio accepts any string as the API key; set a sentinel\n\t\t// so LiteLLM's openai-compatible shim doesn't fail an empty\n\t\t// Authorization header.\n\t\tvalues[\"LMSTUDIO_API_KEY\"] = \"lm-studio\"\n\t}\n\tif contains(methods, methodCustomOpenAIAPI) {\n\t\tvalues[\"CUSTOM_OPENAI_API_BASE\"] = strings.TrimSpace(customOpenAIAPIBase)\n\t\tvalues[\"CUSTOM_OPENAI_API_KEY\"] = strings.TrimSpace(customOpenAIAPIKey)\n\t\tvalues[\"CUSTOM_OPENAI_MODEL\"] = strings.TrimSpace(customOpenAIModel)\n\t}\n\n\tif useLangSmith && langSmithKey != \"\" {\n\t\tvalues[\"LANGSMITH_TRACING\"] = \"true\"\n\t\tvalues[\"LANGSMITH_API_KEY\"] = langSmithKey\n\t\tvalues[\"LANGSMITH_PROJECT\"] = \"decepticon\"\n\t}\n\n\t// Anonymous usage telemetry consent. Only sends when a gateway endpoint is\n\t// configured (DECEPTICON_TELEMETRY_ENDPOINT, shipped in .env.example); an\n\t// unset endpoint keeps it dormant even when the user opted in.\n\tvalues[\"DECEPTICON_TELEMETRY\"] = telemetryChoice\n\n\tif err := config.WriteEnvFromEmbed(config.EnvPath(), values); err != nil {\n\t\treturn fmt.Errorf(\"write .env: %w\", err)\n\t}\n\n\t// Summary\n\tfmt.Println()\n\tfmt.Println(ui.Green.Render(\" ✓ Configuration saved\"))\n\tfmt.Println()\n\tfmt.Println(ui.Dim.Render(\" ┌──────────────────────────────────┐\"))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Methods \") + ui.Dim.Render(strings.Join(priority, \", \")))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Profile \") + ui.Dim.Render(profile))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Language \") + ui.Dim.Render(language))\n\tif useLangSmith {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" LangSmith \") + ui.Green.Render(\"enabled\"))\n\t}\n\tif telemetryChoice != \"off\" {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Telemetry \") + ui.Green.Render(telemetryChoice))\n\t}\n\tfmt.Println(ui.Dim.Render(\" │\"))\n\tfmt.Println(ui.Dim.Render(\" │ \") + ui.Dim.Render(config.EnvPath()))\n\tfmt.Println(ui.Dim.Render(\" └──────────────────────────────────┘\"))\n\tfmt.Println()\n\n\t// Install opscontrol as a managed service (ADR-0006). Idempotent\n\t// — re-running onboard re-templates the unit so a launcher\n\t// upgrade picks up the new ExecStart path. On hosts without\n\t// systemd-user / launchd this is a no-op announcement, and the\n\t// launcher's spawn fallback takes over at `decepticon start`.\n\tfmt.Println()\n\tif err := opscontrol.EnsureInstalled(); err != nil {\n\t\t// Non-fatal: onboarding can complete without managed-service\n\t\t// mode, the user will just stay on launcher-spawn fallback.\n\t\tui.Warning(\"opscontrol install skipped: \" + err.Error())\n\t}\n\tfmt.Println()\n\n\t// One-time GitHub star ask — the natural post-onboarding moment.\n\t// Suppressed on subsequent runs by the ack file at\n\t// $DECEPTICON_HOME/.starred, so a re-run of `decepticon onboard\n\t// --reset` does not re-prompt.\n\tstarprompt.PromptIfNotStarred()\n\n\tui.DimText(\" Run 'decepticon' to start the platform\")\n\treturn nil\n}\n\nfunc contains(haystack []string, needle string) bool {\n\treturn slices.Contains(haystack, needle)\n}\n\nfunc nonEmpty(s string) error {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn fmt.Errorf(\"value is required\")\n\t}\n\treturn nil\n}\n\n// optionalClaudeOAuthToken accepts an empty value (the user falls back to the\n// on-disk credentials file) but, when provided, requires the Claude OAuth\n// token shape (`sk-ant-oat01-…`) so a mistyped API key or stray paste is\n// caught at the prompt rather than failing silently at runtime.\nfunc optionalClaudeOAuthToken(s string) error {\n\tt := strings.TrimSpace(s)\n\tif t == \"\" {\n\t\treturn nil\n\t}\n\tif !strings.HasPrefix(t, \"sk-ant-oat01-\") {\n\t\treturn fmt.Errorf(\"expected a `claude setup-token` token starting with sk-ant-oat01- (leave blank to use the credentials file)\")\n\t}\n\treturn nil\n}\n\nfunc boolStr(b bool) string {\n\tif b {\n\t\treturn \"true\"\n\t}\n\treturn \"false\"\n}\n\n// buildOllamaModelField returns the OLLAMA_MODEL form field that\n// matches the host probe outcome: strict Select when tool-capable\n// models are pulled, otherwise a remediation Note (different message\n// for the reachable-but-no-tools case vs unreachable Ollama).\nfunc buildOllamaModelField(probe ollamaProbeResult, selected *string) huh.Field {\n\tif len(probe.ToolCapableModels) > 0 {\n\t\toptions := make([]huh.Option[string], 0, len(probe.ToolCapableModels))\n\t\tfor _, m := range probe.ToolCapableModels {\n\t\t\toptions = append(options, huh.NewOption(m, m))\n\t\t}\n\t\tif !slices.Contains(probe.ToolCapableModels, *selected) {\n\t\t\t*selected = probe.ToolCapableModels[0]\n\t\t}\n\t\treturn huh.NewSelect[string]().\n\t\t\tTitle(\"OLLAMA_MODEL\").\n\t\t\tDescription(\"Tool-capable models found on your host. Decepticon\\nagents always emit tool calls — these are the only\\nmodels the wizard will accept.\").\n\t\t\tOptions(options...).\n\t\t\tValue(selected)\n\t}\n\n\tif probe.Reachable {\n\t\treturn huh.NewNote().\n\t\t\tTitle(\"OLLAMA_MODEL — no tool-capable models found\").\n\t\t\tDescription(\"Ollama is reachable but none of your pulled models\\nadvertise the 'tools' capability. Decepticon agents\\nalways emit tool calls, so a model without tool\\nsupport cannot power them.\\n\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n\t}\n\n\treturn huh.NewNote().\n\t\tTitle(\"OLLAMA_MODEL — Ollama not reachable\").\n\t\tDescription(\"Could not reach Ollama at \" + defaultOllamaAPIBase + \".\\n\\nMost likely Ollama isn't running or is bound to\\n127.0.0.1 only (which the Decepticon container can't\\nsee). Launch it on all interfaces, pull a tool-capable\\nmodel, then re-run the wizard:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n}\n\n// ollamaUnusableError surfaces the post-form remediation when the user\n// picked Ollama but the probe found nothing usable. Two flavors so the\n// hint matches whichever in-form Note was shown.\nfunc ollamaUnusableError(probe ollamaProbeResult, baseURL string) error {\n\tif probe.Reachable {\n\t\treturn fmt.Errorf(\n\t\t\t\"Ollama selected but no tool-capable models found on the host.\\n\" +\n\t\t\t\t\"Decepticon agents always emit tool calls — pull a tool-capable\\n\" +\n\t\t\t\t\"model and verify it advertises tools, then re-run:\\n\\n\" +\n\t\t\t\t\" ollama pull qwen3-coder:30b\\n\" +\n\t\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\" +\n\t\t\t\t\" decepticon onboard --reset\")\n\t}\n\treturn fmt.Errorf(\n\t\t\"Ollama selected but the host probe could not reach %s.\\n\"+\n\t\t\t\"Make sure Ollama is running and bound to all interfaces (the\\n\"+\n\t\t\t\"default 127.0.0.1 binding is invisible to containers), then\\n\"+\n\t\t\t\"pull a tool-capable model and re-run:\\n\\n\"+\n\t\t\t\" OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\"+\n\t\t\t\" ollama pull qwen3-coder:30b\\n\"+\n\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\"+\n\t\t\t\" decepticon onboard --reset\",\n\t\tbaseURL)\n}\n"}, "12e089b893ab1f9a6c6dccadc8359d72482ba1b1": {"t": 1785755387.4616601, "n": 44699, "h": "8ae5a5f85525eb47", "c": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/huh/v2\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/cmd/opscontrol\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/platform\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/starprompt\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui\"\n\t\"github.com/spf13/cobra\"\n)\n\n// onboardOllamaProbeBudget bounds the wait for the background Ollama\n// discovery probe at form-construction time. Per-request timeout\n// (ollama_models.go) is much shorter; this is the worst-case across\n// all candidate URLs.\nconst onboardOllamaProbeBudget = 3 * time.Second\n\nvar resetFlag bool\n\nvar onboardCmd = &cobra.Command{\n\tUse: \"onboard\",\n\tShort: \"Configure Decepticon (auth methods, model profile, observability)\",\n\tRunE: runOnboard,\n}\n\nfunc init() {\n\tonboardCmd.Flags().BoolVar(&resetFlag, \"reset\", false, \"Reconfigure even if .env already exists\")\n\trootCmd.AddCommand(onboardCmd)\n}\n\n// claudeSetupTokenDocsURL is the official Claude Code page covering\n// `claude setup-token` (mint a long-lived 1-year OAuth token for headless use).\nconst claudeSetupTokenDocsURL = \"https://code.claude.com/docs/en/authentication\"\n\n// AuthMethod identifiers — must match decepticon/llm/models.py::AuthMethod.\nconst (\n\tmethodAnthropicOAuth = \"anthropic_oauth\"\n\tmethodAnthropicAPI = \"anthropic_api\"\n\tmethodOpenAIOAuth = \"openai_oauth\"\n\tmethodOpenAIAPI = \"openai_api\"\n\tmethodGoogleOAuth = \"google_oauth\"\n\tmethodGoogleAPI = \"google_api\"\n\tmethodMiniMaxAPI = \"minimax_api\"\n\tmethodDeepSeekAPI = \"deepseek_api\"\n\tmethodXAIAPI = \"xai_api\"\n\tmethodGrokOAuth = \"grok_oauth\"\n\tmethodMistralAPI = \"mistral_api\"\n\tmethodOpenRouterAPI = \"openrouter_api\"\n\tmethodNvidiaAPI = \"nvidia_api\"\n\tmethodCopilotOAuth = \"copilot_oauth\"\n\tmethodPerplexityOAuth = \"perplexity_oauth\"\n\tmethodOllamaLocal = \"ollama_local\"\n\tmethodOllamaCloud = \"ollama_cloud\"\n\t// Cloud gateways added in the OpenClaude provider migration.\n\tmethodBedrockAPI = \"bedrock_api\"\n\tmethodVertexAPI = \"vertex_api\"\n\tmethodAzureAPI = \"azure_api\"\n\tmethodGroqAPI = \"groq_api\"\n\tmethodTogetherAPI = \"together_api\"\n\tmethodFireworksAPI = \"fireworks_api\"\n\tmethodCohereAPI = \"cohere_api\"\n\tmethodMoonshotAPI = \"moonshot_api\"\n\tmethodZaiAPI = \"zai_api\"\n\tmethodDashscopeAPI = \"dashscope_api\"\n\tmethodGitHubModelsAPI = \"github_models_api\"\n\tmethodLMStudioLocal = \"lmstudio_local\"\n\tmethodCustomOpenAIAPI = \"custom_openai_api\"\n)\n\n// Defaults shown in form placeholders for the new providers.\nconst (\n\tdefaultLMStudioAPIBase = \"http://host.docker.internal:1234/v1\"\n\tdefaultLMStudioModel = \"qwen2.5-coder-7b-instruct\"\n\tdefaultAzureAPIVersion = \"2024-08-01-preview\"\n)\n\n// Default Ollama wiring shown to OSS users. `host.docker.internal`\n// is the universal answer regardless of where Ollama runs (macOS host,\n// WSL2 distro, native Linux): from inside Decepticon's containers it\n// resolves to the host network namespace via the\n// `extra_hosts: [host.docker.internal:host-gateway]` entry on the\n// litellm service in docker-compose.yml. `localhost` is never the\n// right answer here — that's the container itself.\n//\n// The host's Ollama must additionally be bound to 0.0.0.0 (the default\n// 127.0.0.1 binding only accepts host-side connections); the wizard\n// surfaces this requirement to the user.\n//\n// The default model is the smallest/fastest one most laptops can\n// actually run; users with a GPU will pick something like qwen3-coder:30b.\nconst (\n\tdefaultOllamaAPIBase = \"http://host.docker.internal:11434\"\n\tdefaultOllamaModel = \"llama3.2\"\n)\n\n// methodOrder is the priority order surfaced in the wizard. The\n// resulting DECEPTICON_AUTH_PRIORITY preserves this order, filtered\n// to the methods the user actually selected. OAuth precedes the\n// matching API on purpose: a subscription primary should fall back\n// to the paid API only when the subscription quota is exhausted.\n// Ollama sits last: cloud providers are usually preferred when both\n// are configured, but a user wanting to lead with local-only inference\n// can reorder the priority manually in .env.\nvar methodOrder = []string{\n\tmethodAnthropicOAuth,\n\tmethodAnthropicAPI,\n\tmethodOpenAIOAuth,\n\tmethodOpenAIAPI,\n\tmethodGoogleOAuth,\n\tmethodGoogleAPI,\n\tmethodMiniMaxAPI,\n\tmethodDeepSeekAPI,\n\tmethodXAIAPI,\n\tmethodGrokOAuth,\n\tmethodMistralAPI,\n\tmethodOpenRouterAPI,\n\tmethodNvidiaAPI,\n\tmethodCopilotOAuth,\n\tmethodPerplexityOAuth,\n\t// Cloud gateways — anthropic-via-cloud paths first, then\n\t// other multi-vendor hubs.\n\tmethodBedrockAPI,\n\tmethodVertexAPI,\n\tmethodAzureAPI,\n\tmethodGitHubModelsAPI,\n\tmethodGroqAPI,\n\tmethodTogetherAPI,\n\tmethodFireworksAPI,\n\tmethodCohereAPI,\n\tmethodMoonshotAPI,\n\tmethodZaiAPI,\n\tmethodDashscopeAPI,\n\t// Local last so cloud-preferred default still picks remote\n\t// providers as primary; users can re-order in .env.\n\tmethodLMStudioLocal,\n\tmethodOllamaLocal,\n\tmethodOllamaCloud,\n\tmethodCustomOpenAIAPI,\n}\n\n// systemCheckGroup renders the host-environment snapshot as the first\n// step of onboarding. It detects OS/architecture (so the user sees the\n// machine is recognized — be it Windows, macOS, a Linux pentest distro,\n// or a Raspberry Pi) and the Docker readiness state, with OS-specific\n// remediation when something is missing. It never blocks: credentials\n// can be configured before Docker is sorted out.\nfunc systemCheckGroup(sys platform.SystemInfo) *huh.Group {\n\tvar b strings.Builder\n\n\tosLine := sys.OSLabel() + \" (\" + sys.Arch + \")\"\n\tif sys.IsWSL {\n\t\tosLine += \" · WSL2\"\n\t}\n\tb.WriteString(\"OS \" + osLine + \"\\n\")\n\n\tdocker := \"not installed\"\n\tif sys.DockerInstalled {\n\t\tdocker = \"installed\"\n\t\tif sys.DockerRunning {\n\t\t\tdocker += \" · running\"\n\t\t} else {\n\t\t\tdocker += \" · stopped\"\n\t\t}\n\t\tif sys.ComposeAvailable {\n\t\t\tdocker += \" · compose v2\"\n\t\t}\n\t}\n\tb.WriteString(\"Docker \" + docker + \"\\n\\n\")\n\n\tif sys.Ready() {\n\t\tb.WriteString(\"This system is ready to run Decepticon.\")\n\t} else {\n\t\tb.WriteString(sys.DockerHint() + \"\\n\")\n\t\tb.WriteString(\"You can still configure credentials now and sort out Docker before launching.\")\n\t}\n\n\treturn huh.NewGroup(\n\t\thuh.NewNote().\n\t\t\tTitle(\"System Check\").\n\t\t\tDescription(b.String()),\n\t)\n}\n\nfunc runOnboard(cmd *cobra.Command, args []string) error {\n\tif config.EnvExists() && !resetFlag {\n\t\tui.Info(\".env already configured at \" + config.EnvPath())\n\t\tui.DimText(\"Run 'decepticon onboard --reset' to reconfigure\")\n\t\treturn nil\n\t}\n\n\t// Kick off Ollama discovery in the background so the network\n\t// round-trip overlaps with huh's startup work; the OLLAMA_MODEL\n\t// field type depends on the result (Select vs remediation Note).\n\tprobeCh := make(chan ollamaProbeResult, 1)\n\tgo func() {\n\t\tprobeCh <- probeOllamaForOnboard(defaultOllamaAPIBase)\n\t}()\n\n\tvar (\n\t\tmethods []string\n\t\tanthropicKey string\n\t\tclaudeOAuthToken string\n\t\topenaiKey string\n\t\tgeminiKey string\n\t\tminimaxKey string\n\t\tdeepseekKey string\n\t\txaiKey string\n\t\tmistralKey string\n\t\topenrouterKey string\n\t\tnvidiaKey string\n\t\tgeminiSessionCookies string\n\t\tcopilotRefreshToken string\n\t\tgrokSessionToken string\n\t\tperplexitySessionToken string\n\t\tollamaAPIBase = defaultOllamaAPIBase\n\t\tollamaModel = defaultOllamaModel\n\t\t// Cloud gateways\n\t\tawsAccessKeyID string\n\t\tawsSecretAccessKey string\n\t\tawsRegion = \"us-east-1\"\n\t\tvertexCredsPath string\n\t\tvertexProject string\n\t\tvertexLocation = \"us-central1\"\n\t\tazureAPIKey string\n\t\tazureAPIBase string\n\t\tazureAPIVersion = defaultAzureAPIVersion\n\t\tgroqKey string\n\t\ttogetherKey string\n\t\tfireworksKey string\n\t\tcohereKey string\n\t\tmoonshotKey string\n\t\tzaiKey string\n\t\tdashscopeKey string\n\t\tgithubToken string\n\t\t// Cloud Ollama\n\t\tollamaCloudAPIBase string\n\t\tollamaCloudAPIKey string\n\t\tollamaCloudModel string\n\t\t// LM Studio (local OpenAI-compatible)\n\t\tlmStudioAPIBase = defaultLMStudioAPIBase\n\t\tlmStudioModel = defaultLMStudioModel\n\t\t// Custom OpenAI-compatible endpoint\n\t\tcustomOpenAIAPIBase string\n\t\tcustomOpenAIAPIKey string\n\t\tcustomOpenAIModel string\n\t\tprofile string\n\t\tlanguage = \"en\"\n\t\tuseLangSmith bool\n\t\tlangSmithKey string\n\t)\n\t// Block on the probe (zero-value result on timeout means\n\t// \"unreachable\" — drops through to the remediation Note).\n\t// time.NewTimer + Stop avoids the time.After timer leak when the\n\t// probe finishes first.\n\tprobeTimer := time.NewTimer(onboardOllamaProbeBudget)\n\tvar ollamaProbe ollamaProbeResult\n\tselect {\n\tcase ollamaProbe = <-probeCh:\n\t\tprobeTimer.Stop()\n\tcase <-probeTimer.C:\n\t}\n\tollamaModelField := buildOllamaModelField(ollamaProbe, &ollamaModel)\n\n\t// Probe the host environment so the wizard can confirm — up front —\n\t// that this machine (whatever OS/arch it is) can actually run the\n\t// Docker stack, and surface remediation before the user spends time\n\t// entering credentials.\n\tsysInfo := platform.Detect()\n\n\tform := huh.NewForm(\n\t\t// Step 0: system check.\n\t\tsystemCheckGroup(sysInfo),\n\n\t\t// Intro\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Decepticon Setup\").\n\t\t\t\tDescription(\"Configure auth methods, model profile, and\\nobservability.\\n\\nUse ↑↓ to navigate, space to toggle, Enter to confirm.\"),\n\t\t),\n\n\t\t// Step 1: Auth methods (multi-select)\n\t\thuh.NewGroup(\n\t\t\thuh.NewMultiSelect[string]().\n\t\t\t\tTitle(\"Auth Methods\").\n\t\t\t\tDescription(\"Press SPACE to toggle, ENTER to confirm.\\nUse ↑↓ to scroll, '/' to filter the list.\\nPick every credential you have — each is an\\nindependent fallback in priority order.\").\n\t\t\t\tFilterable(true).\n\t\t\t\tHeight(15).\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"Claude Code OAuth — Anthropic subscription (auth/*)\", methodAnthropicOAuth),\n\t\t\t\t\thuh.NewOption(\"Anthropic API Key — sk-ant-...\", methodAnthropicAPI),\n\t\t\t\t\thuh.NewOption(\"ChatGPT OAuth — ChatGPT Pro/Plus/Team subscription via `codex login` (auth/gpt-*)\", methodOpenAIOAuth),\n\t\t\t\t\thuh.NewOption(\"OpenAI API Key — sk-...\", methodOpenAIAPI),\n\t\t\t\t\thuh.NewOption(\"Google API Key — AIza... (Gemini)\", methodGoogleAPI),\n\t\t\t\t\thuh.NewOption(\"MiniMax API Key — eyJ...\", methodMiniMaxAPI),\n\t\t\t\t\thuh.NewOption(\"DeepSeek API Key — sk-...\", methodDeepSeekAPI),\n\t\t\t\t\thuh.NewOption(\"xAI API Key — xai-... (Grok)\", methodXAIAPI),\n\t\t\t\t\thuh.NewOption(\"Mistral API Key — (no fixed prefix)\", methodMistralAPI),\n\t\t\t\t\thuh.NewOption(\"OpenRouter API Key — sk-or-...\", methodOpenRouterAPI),\n\t\t\t\t\thuh.NewOption(\"Nvidia NIM API Key — nvapi-...\", methodNvidiaAPI),\n\t\t\t\t\thuh.NewOption(\"Gemini Advanced — Google One AI Premium subscription (gemini-sub/*)\", methodGoogleOAuth),\n\t\t\t\t\thuh.NewOption(\"SuperGrok — X Premium+ Grok subscription (grok-sub/*)\", methodGrokOAuth),\n\t\t\t\t\thuh.NewOption(\"GitHub Copilot Pro — Copilot subscription (copilot/*)\", methodCopilotOAuth),\n\t\t\t\t\thuh.NewOption(\"Perplexity Pro — Perplexity subscription (pplx-sub/*)\", methodPerplexityOAuth),\n\t\t\t\t\thuh.NewOption(\"AWS Bedrock — Anthropic models on AWS (bedrock/*)\", methodBedrockAPI),\n\t\t\t\t\thuh.NewOption(\"GCP Vertex AI — Anthropic + Gemini on GCP (vertex_ai/*)\", methodVertexAPI),\n\t\t\t\t\thuh.NewOption(\"Azure OpenAI — Azure-hosted GPT deployments (azure/*)\", methodAzureAPI),\n\t\t\t\t\thuh.NewOption(\"GitHub Models — GPT family via GitHub PAT (github/*)\", methodGitHubModelsAPI),\n\t\t\t\t\thuh.NewOption(\"Groq Cloud — LPU-accelerated Llama (groq/*)\", methodGroqAPI),\n\t\t\t\t\thuh.NewOption(\"Together AI — Llama / Mixtral hub (together_ai/*)\", methodTogetherAPI),\n\t\t\t\t\thuh.NewOption(\"Fireworks AI — Llama / Mixtral hub (fireworks_ai/*)\", methodFireworksAPI),\n\t\t\t\t\thuh.NewOption(\"Cohere Command — Command-A / Command-R (cohere/*)\", methodCohereAPI),\n\t\t\t\t\thuh.NewOption(\"Moonshot Kimi K2 — Kimi K2 (moonshot/*)\", methodMoonshotAPI),\n\t\t\t\t\thuh.NewOption(\"Z.ai GLM-4.5 — GLM-4.5 / GLM-4.5-Air (zai/*)\", methodZaiAPI),\n\t\t\t\t\thuh.NewOption(\"Alibaba DashScope — Qwen Max/Plus/Turbo (dashscope/*)\", methodDashscopeAPI),\n\t\t\t\t\thuh.NewOption(\"LM Studio (local) — local OpenAI-compatible server (lm_studio/*)\", methodLMStudioLocal),\n\t\t\t\t\thuh.NewOption(\"Local LLM (Ollama) — any pulled model, no API key\", methodOllamaLocal),\n\t\t\t\t\thuh.NewOption(\"Ollama Cloud — hosted Ollama (ollama_chat/* via cloud)\", methodOllamaCloud),\n\t\t\t\t\thuh.NewOption(\"Custom OpenAI Endpoint — bring-your-own OpenAI-compatible URL\", methodCustomOpenAIAPI),\n\t\t\t\t).\n\t\t\t\tValue(&methods).\n\t\t\t\tValidate(func(s []string) error {\n\t\t\t\t\tif len(s) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"select at least one credential\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"1 / 5 · Credentials\").\n\t\t\tDescription(\"Select all that apply\"),\n\n\t\t// Step 2-claude-oauth: Claude Code subscription long-lived token.\n\t\t// Run `claude setup-token` once (any machine, interactive browser\n\t\t// OAuth) → paste the 1-year token here. It is honored via the\n\t\t// ANTHROPIC_OAUTH_TOKEN env override, which carries expiresAt=0 so it\n\t\t// is never auto-refreshed — headless-safe for 24/7 / cloud, with no\n\t\t// live Claude Code session and no ~/.claude/.credentials.json mount.\n\t\t// Optional: leave blank to fall back to the rotating on-disk\n\t\t// credentials from an interactive `claude` login.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Claude Code Subscription — 1-year token\").\n\t\t\t\tDescription(\n\t\t\t\t\t\"Two steps:\\n\\n\"+\n\t\t\t\t\t\t\"1) Generate a 1-year token — run:\\n\"+\n\t\t\t\t\t\t\" claude setup-token\\n\"+\n\t\t\t\t\t\t\" (opens browser OAuth; needs a Pro / Max / Team /\\n\"+\n\t\t\t\t\t\t\" Enterprise plan). Docs / issue link:\\n\"+\n\t\t\t\t\t\t\" \"+claudeSetupTokenDocsURL+\"\\n\\n\"+\n\t\t\t\t\t\t\"2) Paste the sk-ant-oat01-… token it prints into the\\n\"+\n\t\t\t\t\t\t\" field below.\\n\\n\"+\n\t\t\t\t\t\t\"Headless-safe: never refreshed, no live Claude Code\\n\"+\n\t\t\t\t\t\t\"session. Leave blank to use a rotating\\n\"+\n\t\t\t\t\t\t\"~/.claude/.credentials.json from a `claude` login.\",\n\t\t\t\t),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"ANTHROPIC_OAUTH_TOKEN\").\n\t\t\t\tPlaceholder(\"sk-ant-oat01-... (leave blank to use credentials file)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&claudeOAuthToken).\n\t\t\t\tValidate(optionalClaudeOAuthToken),\n\t\t).Title(\"2 / 5 · Claude Code Subscription\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicOAuth) }),\n\n\t\t// Step 2a: Anthropic API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Anthropic API Key\").\n\t\t\t\tPlaceholder(\"sk-ant-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&anthropicKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Anthropic API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicAPI) }),\n\n\t\t// Step 2c: OpenAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenAI API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenAI API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIAPI) }),\n\n\t\t// Step 2c: Google API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Google (Gemini) API Key\").\n\t\t\t\tPlaceholder(\"AIza...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Google API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleAPI) }),\n\n\t\t// Step 2d: MiniMax API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MiniMax API Key\").\n\t\t\t\tPlaceholder(\"eyJ...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&minimaxKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · MiniMax API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMiniMaxAPI) }),\n\n\t\t// Step 2d-i: DeepSeek API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DeepSeek API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&deepseekKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DeepSeek API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDeepSeekAPI) }),\n\n\t\t// Step 2d-ii: xAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"xAI API Key\").\n\t\t\t\tPlaceholder(\"xai-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&xaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · xAI API (Grok)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodXAIAPI) }),\n\n\t\t// Step 2d-iii: Mistral API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Mistral API Key\").\n\t\t\t\tPlaceholder(\"paste your Mistral API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&mistralKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Mistral API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMistralAPI) }),\n\n\t\t// Step 2e: OpenRouter API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenRouter API Key\").\n\t\t\t\tPlaceholder(\"sk-or-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openrouterKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenRouter API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenRouterAPI) }),\n\n\t\t// Step 2f: Nvidia NIM API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Nvidia NIM API Key\").\n\t\t\t\tPlaceholder(\"nvapi-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&nvidiaKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Nvidia NIM API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodNvidiaAPI) }),\n\n\t\t// Step 2-oauth-i: Gemini Advanced subscription\n\t\t// Multi-cookie value (NID + Secure-1PSID + Secure-1PSIDTS, etc.)\n\t\t// joined with semicolons. Optional — power users can drop a\n\t\t// tokens.json under ~/.config/gemini/ instead and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Gemini Advanced Subscription\").\n\t\t\t\tDescription(\"Open gemini.google.com → DevTools → Application →\\nCookies → gemini.google.com. Copy the values of\\nNID, __Secure-1PSID, __Secure-1PSIDTS as a single\\nsemicolon-joined string. Or leave blank to use\\nGEMINI_ACCESS_TOKEN / ~/.config/gemini/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GEMINI_SESSION_COOKIES\").\n\t\t\t\tPlaceholder(\"NID=...; __Secure-1PSID=... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiSessionCookies),\n\t\t).Title(\"2 / 5 · Gemini Advanced\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleOAuth) }),\n\n\t\t// Step 2-oauth-ii: SuperGrok subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"SuperGrok Subscription\").\n\t\t\t\tDescription(\"Open grok.com → DevTools → Application →\\nCookies → grok.com → copy the value of\\n`auth_token`. Or leave blank to use\\nGROK_ACCESS_TOKEN / ~/.config/grok/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROK_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste auth_token cookie value (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&grokSessionToken),\n\t\t).Title(\"2 / 5 · SuperGrok\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGrokOAuth) }),\n\n\t\t// Step 2-oauth-iii: GitHub Copilot Pro subscription\n\t\t// Refresh token rotation, not a single session cookie. Devs can\n\t\t// extract one from VSCode's Copilot extension config or follow\n\t\t// gh-copilot-cli onboarding. Optional via tokens.json fallback.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GitHub Copilot Pro Subscription\").\n\t\t\t\tDescription(\"Provide a Copilot refresh token (long-lived, used\\nto rotate access tokens). Or leave blank to use\\nCOPILOT_ACCESS_TOKEN / ~/.config/copilot/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COPILOT_REFRESH_TOKEN\").\n\t\t\t\tPlaceholder(\"ghu_... or ghr_... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&copilotRefreshToken),\n\t\t).Title(\"2 / 5 · Copilot Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCopilotOAuth) }),\n\n\t\t// Step 2-oauth-iv: Perplexity Pro subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Perplexity Pro Subscription\").\n\t\t\t\tDescription(\"Open perplexity.ai → DevTools → Application →\\nCookies → perplexity.ai → copy the value of\\n`next-auth.session-token`. Or leave blank to use\\nPERPLEXITY_ACCESS_TOKEN / ~/.config/perplexity/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"PERPLEXITY_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste next-auth.session-token (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&perplexitySessionToken),\n\t\t).Title(\"2 / 5 · Perplexity Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodPerplexityOAuth) }),\n\n\t\t// Step 2g: Local Ollama. The OLLAMA_MODEL field is built from\n\t\t// the host probe (buildOllamaModelField): a strict Select when\n\t\t// tool-capable models are pulled, a remediation Note otherwise.\n\t\t// The post-form gate refuses to write .env in the Note cases.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Local Ollama\").\n\t\t\t\tDescription(\"Ollama must already be running on your host with\\nat least one tool-capable model pulled. Launch it\\nbound to all interfaces so the Decepticon container\\ncan reach it:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\\nThe default 127.0.0.1 binding only accepts host-side\\nconnections — containers won't see it.\\n\\nThe default URL `host.docker.internal:11434` works\\non macOS, Linux, and WSL2 (with or without Docker\\nDesktop). Only change it for remote / custom setups.\\nNote: the model list is probed against the default URL\\nat wizard start; if you customize OLLAMA_API_BASE,\\nfinish the wizard then re-run 'decepticon onboard\\n--reset' so the probe targets the new endpoint.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_API_BASE\").\n\t\t\t\tPlaceholder(defaultOllamaAPIBase).\n\t\t\t\tValue(&ollamaAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\tollamaModelField,\n\t\t).Title(\"2 / 5 · Local LLM (Ollama)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaLocal) }),\n\n\t\t// Step 2-cloud-i: AWS Bedrock — three-field group (key + secret + region)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"AWS Bedrock\").\n\t\t\t\tDescription(\"Bedrock uses AWS SigV4 — provide IAM access key,\\nsecret, and the region where Anthropic models are\\nenabled (us-east-1 or us-west-2 typically).\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_ACCESS_KEY_ID\").\n\t\t\t\tPlaceholder(\"AKIA...\").\n\t\t\t\tValue(&awsAccessKeyID).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_SECRET_ACCESS_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&awsSecretAccessKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_REGION\").\n\t\t\t\tPlaceholder(\"us-east-1\").\n\t\t\t\tValue(&awsRegion).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · AWS Bedrock\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodBedrockAPI) }),\n\n\t\t// Step 2-cloud-ii: GCP Vertex AI — service-account JSON path + project + region\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GCP Vertex AI\").\n\t\t\t\tDescription(\"Vertex AI uses a Google Cloud service-account JSON.\\nDownload one from IAM & Admin → Service Accounts and\\npaste the absolute file path. Project + region must\\nmatch where you enabled Anthropic + Gemini models.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GOOGLE_APPLICATION_CREDENTIALS\").\n\t\t\t\tPlaceholder(\"/abs/path/to/service-account.json\").\n\t\t\t\tValue(&vertexCredsPath).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"VERTEXAI_PROJECT\").\n\t\t\t\tPlaceholder(\"my-gcp-project-id\").\n\t\t\t\tValue(&vertexProject).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"VERTEXAI_LOCATION\").\n\t\t\t\tPlaceholder(\"us-central1\").\n\t\t\t\tValue(&vertexLocation).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · GCP Vertex AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodVertexAPI) }),\n\n\t\t// Step 2-cloud-iii: Azure OpenAI — key + endpoint + version\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Azure OpenAI Service\").\n\t\t\t\tDescription(\"Endpoint is your azure-openai resource URL,\\ne.g. https://my-resource.openai.azure.com.\\nThe deployment names map 1:1 to gpt-5.5/gpt-5.4/...\\nso the model matrix in DF resolves out of the box.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&azureAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_BASE\").\n\t\t\t\tPlaceholder(\"https://<resource>.openai.azure.com\").\n\t\t\t\tValue(&azureAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_VERSION\").\n\t\t\t\tPlaceholder(defaultAzureAPIVersion).\n\t\t\t\tValue(&azureAPIVersion).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Azure OpenAI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAzureAPI) }),\n\n\t\t// Step 2-cloud-iv: GitHub Models — PAT\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GITHUB_TOKEN\").\n\t\t\t\tDescription(\"Fine-grained PAT with the 'models' permission scope.\\nGenerate at github.com/settings/personal-access-tokens.\").\n\t\t\t\tPlaceholder(\"github_pat_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&githubToken).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · GitHub Models\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGitHubModelsAPI) }),\n\n\t\t// Step 2-cloud-v: Groq\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROQ_API_KEY\").\n\t\t\t\tPlaceholder(\"gsk_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&groqKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Groq Cloud\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGroqAPI) }),\n\n\t\t// Step 2-cloud-vi: Together AI\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"TOGETHER_API_KEY\").\n\t\t\t\tPlaceholder(\"paste your Together AI API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&togetherKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Together AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodTogetherAPI) }),\n\n\t\t// Step 2-cloud-vii: Fireworks AI\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"FIREWORKS_API_KEY\").\n\t\t\t\tPlaceholder(\"fw_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&fireworksKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Fireworks AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodFireworksAPI) }),\n\n\t\t// Step 2-cloud-viii: Cohere\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COHERE_API_KEY\").\n\t\t\t\tPlaceholder(\"paste your Cohere API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&cohereKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Cohere\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCohereAPI) }),\n\n\t\t// Step 2-cloud-ix: Moonshot Kimi K2\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MOONSHOT_API_KEY\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&moonshotKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Moonshot Kimi K2\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMoonshotAPI) }),\n\n\t\t// Step 2-cloud-x: Z.ai GLM-4.5\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"ZAI_API_KEY\").\n\t\t\t\tDescription(\"Z.ai is OpenAI-compatible — base URL is hard-wired to\\nhttps://api.z.ai/api/paas/v4. Get a key at z.ai/manage.\").\n\t\t\t\tPlaceholder(\"paste your Z.ai API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&zaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Z.ai GLM-4.5\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodZaiAPI) }),\n\n\t\t// Step 2-cloud-xi: Alibaba DashScope (Qwen)\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DASHSCOPE_API_KEY\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&dashscopeKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DashScope (Qwen)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDashscopeAPI) }),\n\n\t\t// Step 2-local-ii: LM Studio (local OpenAI-compatible)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"LM Studio\").\n\t\t\t\tDescription(\"LM Studio runs a local OpenAI-compatible server.\\nLaunch the LM Studio app and start the server\\n(Developer tab → Start). The default port is 1234.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LMSTUDIO_API_BASE\").\n\t\t\t\tPlaceholder(defaultLMStudioAPIBase).\n\t\t\t\tValue(&lmStudioAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LMSTUDIO_MODEL\").\n\t\t\t\tDescription(\"Model identifier as shown in LM Studio (e.g.\\nqwen2.5-coder-7b-instruct).\").\n\t\t\t\tPlaceholder(defaultLMStudioModel).\n\t\t\t\tValue(&lmStudioModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · LM Studio\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodLMStudioLocal) }),\n\n\t\t// Step 2-local-iii: Ollama Cloud (hosted)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Ollama Cloud\").\n\t\t\t\tDescription(\"Hosted Ollama — same /api/chat tool-calling endpoint\\nas local Ollama, just behind an API key.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_API_BASE\").\n\t\t\t\tPlaceholder(\"https://api.ollama.com/v1\").\n\t\t\t\tValue(&ollamaCloudAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&ollamaCloudAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_MODEL\").\n\t\t\t\tPlaceholder(\"llama3.3:70b\").\n\t\t\t\tValue(&ollamaCloudModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Ollama Cloud\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaCloud) }),\n\n\t\t// Step 2-custom: Custom OpenAI-compatible endpoint\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Custom OpenAI-compatible Endpoint\").\n\t\t\t\tDescription(\"Point at any OpenAI-compatible server (gateways,\\nself-hosted vLLM, internal LiteLLM, etc.). The model\\nname is sent verbatim to the upstream — match what\\nyour gateway exposes.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_API_BASE\").\n\t\t\t\tPlaceholder(\"https://gateway.example.com/v1\").\n\t\t\t\tValue(&customOpenAIAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&customOpenAIAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_MODEL\").\n\t\t\t\tPlaceholder(\"gpt-4o-mini\").\n\t\t\t\tValue(&customOpenAIModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Custom OpenAI Endpoint\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCustomOpenAIAPI) }),\n\n\t\t// Step 3: Model profile\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Model Profile\").\n\t\t\t\tDescription(\"eco per-agent tier (recommended)\\nmax every agent on HIGH (expensive)\\ntest every agent on LOW (development)\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"eco — per-agent tier (recommended)\", \"eco\"),\n\t\t\t\t\thuh.NewOption(\"max — every agent on HIGH (expensive)\", \"max\"),\n\t\t\t\t\thuh.NewOption(\"test — every agent on LOW (development)\", \"test\"),\n\t\t\t\t).\n\t\t\t\tValue(&profile),\n\t\t).Title(\"3 / 5 · Profile\"),\n\n\t\t// Step 4: Language\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Agent Language\").\n\t\t\t\tDescription(\"Language for all agent prose output (menus, questions,\\nsummaries, errors). Technical output stays in English.\\nCountry codes (dk, se, jp, cn) are auto-resolved.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"en — English (default)\", \"en\"),\n\t\t\t\t\thuh.NewOption(\"no — Norwegian\", \"no\"),\n\t\t\t\t\thuh.NewOption(\"da — Danish\", \"da\"),\n\t\t\t\t\thuh.NewOption(\"sv — Swedish\", \"sv\"),\n\t\t\t\t\thuh.NewOption(\"fi — Finnish\", \"fi\"),\n\t\t\t\t\thuh.NewOption(\"is — Icelandic\", \"is\"),\n\t\t\t\t\thuh.NewOption(\"ko — Korean\", \"ko\"),\n\t\t\t\t\thuh.NewOption(\"ja — Japanese\", \"ja\"),\n\t\t\t\t\thuh.NewOption(\"zh — Chinese\", \"zh\"),\n\t\t\t\t\thuh.NewOption(\"zh-tw — Traditional Chinese\", \"zh-tw\"),\n\t\t\t\t\thuh.NewOption(\"es — Spanish\", \"es\"),\n\t\t\t\t\thuh.NewOption(\"pt — Portuguese\", \"pt\"),\n\t\t\t\t\thuh.NewOption(\"pt-br — Brazilian Portuguese\", \"pt-br\"),\n\t\t\t\t\thuh.NewOption(\"de — German\", \"de\"),\n\t\t\t\t\thuh.NewOption(\"fr — French\", \"fr\"),\n\t\t\t\t\thuh.NewOption(\"nl — Dutch\", \"nl\"),\n\t\t\t\t\thuh.NewOption(\"it — Italian\", \"it\"),\n\t\t\t\t\thuh.NewOption(\"pl — Polish\", \"pl\"),\n\t\t\t\t\thuh.NewOption(\"cs — Czech\", \"cs\"),\n\t\t\t\t\thuh.NewOption(\"uk — Ukrainian\", \"uk\"),\n\t\t\t\t\thuh.NewOption(\"ro — Romanian\", \"ro\"),\n\t\t\t\t\thuh.NewOption(\"hr — Croatian\", \"hr\"),\n\t\t\t\t\thuh.NewOption(\"bg — Bulgarian\", \"bg\"),\n\t\t\t\t\thuh.NewOption(\"ru — Russian\", \"ru\"),\n\t\t\t\t\thuh.NewOption(\"el — Greek\", \"el\"),\n\t\t\t\t\thuh.NewOption(\"hu — Hungarian\", \"hu\"),\n\t\t\t\t\thuh.NewOption(\"tr — Turkish\", \"tr\"),\n\t\t\t\t\thuh.NewOption(\"ar — Arabic\", \"ar\"),\n\t\t\t\t\thuh.NewOption(\"fa — Persian\", \"fa\"),\n\t\t\t\t\thuh.NewOption(\"he — Hebrew\", \"he\"),\n\t\t\t\t\thuh.NewOption(\"hi — Hindi\", \"hi\"),\n\t\t\t\t\thuh.NewOption(\"th — Thai\", \"th\"),\n\t\t\t\t\thuh.NewOption(\"vi — Vietnamese\", \"vi\"),\n\t\t\t\t\thuh.NewOption(\"id — Indonesian\", \"id\"),\n\t\t\t\t\thuh.NewOption(\"ms — Malay\", \"ms\"),\n\t\t\t\t\thuh.NewOption(\"tl — Filipino\", \"tl\"),\n\t\t\t\t\thuh.NewOption(\"sw — Swahili\", \"sw\"),\n\t\t\t\t\thuh.NewOption(\"af — Afrikaans\", \"af\"),\n\t\t\t\t\thuh.NewOption(\"wenyan — 文言文 + English technical terms\", \"wenyan\"),\n\t\t\t\t).\n\t\t\t\tValue(&language),\n\t\t).Title(\"4 / 5 · Language\"),\n\n\t\t// Step 5a: LangSmith toggle\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Enable LangSmith?\").\n\t\t\t\tDescription(\"LLM observability and trace collection\").\n\t\t\t\tAffirmative(\"Yes\").\n\t\t\t\tNegative(\"No\").\n\t\t\t\tValue(&useLangSmith),\n\t\t).Title(\"5 / 5 · Observability\"),\n\n\t\t// Step 5b: LangSmith key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LangSmith API Key\").\n\t\t\t\tPlaceholder(\"lsv2_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&langSmithKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"5 / 5 · LangSmith\").\n\t\t\tWithHideFunc(func() bool { return !useLangSmith }),\n\t).WithTheme(huh.ThemeFunc(ui.DecepticonTheme))\n\n\tif err := form.Run(); err != nil {\n\t\treturn fmt.Errorf(\"setup cancelled: %w\", err)\n\t}\n\n\t// Strict-mode gate: refuse to write .env when the user picked\n\t// Ollama but the host probe found no tool-capable model. The\n\t// in-form Note shows the same remediation; this is the boundary\n\t// guarantee that a broken setup never ships.\n\tif contains(methods, methodOllamaLocal) && len(ollamaProbe.ToolCapableModels) == 0 {\n\t\treturn ollamaUnusableError(ollamaProbe, ollamaAPIBase)\n\t}\n\n\t// huh.MultiSelect returns selected values in option order, not the\n\t// order the user toggled. Re-derive the priority by walking\n\t// methodOrder and keeping only what the user picked.\n\tpriority := make([]string, 0, len(methods))\n\tfor _, m := range methodOrder {\n\t\tif contains(methods, m) {\n\t\t\tpriority = append(priority, m)\n\t\t}\n\t}\n\n\tvalues := map[string]string{\n\t\t\"DECEPTICON_MODEL_PROFILE\": profile,\n\t\t\"DECEPTICON_LANGUAGE\": language,\n\t\t\"DECEPTICON_AUTH_PRIORITY\": strings.Join(priority, \",\"),\n\t\t\"DECEPTICON_AUTH_CLAUDE_CODE\": boolStr(contains(methods, methodAnthropicOAuth)),\n\t\t\"DECEPTICON_AUTH_CHATGPT\": boolStr(contains(methods, methodOpenAIOAuth)),\n\t\t\"DECEPTICON_AUTH_GEMINI\": boolStr(contains(methods, methodGoogleOAuth)),\n\t\t\"DECEPTICON_AUTH_GROK\": boolStr(contains(methods, methodGrokOAuth)),\n\t\t\"DECEPTICON_AUTH_COPILOT\": boolStr(contains(methods, methodCopilotOAuth)),\n\t\t\"DECEPTICON_AUTH_PERPLEXITY\": boolStr(contains(methods, methodPerplexityOAuth)),\n\t}\n\n\tif anthropicKey != \"\" {\n\t\tvalues[\"ANTHROPIC_API_KEY\"] = anthropicKey\n\t}\n\tif claudeOAuthToken != \"\" {\n\t\t// Long-lived `claude setup-token`. The claude_code handler reads this\n\t\t// as a synthetic credential (expiresAt=0 → never refreshed); no\n\t\t// credentials-file mount or live CC session needed.\n\t\tvalues[\"ANTHROPIC_OAUTH_TOKEN\"] = strings.TrimSpace(claudeOAuthToken)\n\t}\n\tif openaiKey != \"\" {\n\t\tvalues[\"OPENAI_API_KEY\"] = openaiKey\n\t}\n\tif geminiKey != \"\" {\n\t\tvalues[\"GEMINI_API_KEY\"] = geminiKey\n\t}\n\tif minimaxKey != \"\" {\n\t\tvalues[\"MINIMAX_API_KEY\"] = minimaxKey\n\t}\n\tif deepseekKey != \"\" {\n\t\tvalues[\"DEEPSEEK_API_KEY\"] = deepseekKey\n\t}\n\tif xaiKey != \"\" {\n\t\tvalues[\"XAI_API_KEY\"] = xaiKey\n\t}\n\tif mistralKey != \"\" {\n\t\tvalues[\"MISTRAL_API_KEY\"] = mistralKey\n\t}\n\tif openrouterKey != \"\" {\n\t\tvalues[\"OPENROUTER_API_KEY\"] = openrouterKey\n\t}\n\tif nvidiaKey != \"\" {\n\t\tvalues[\"NVIDIA_API_KEY\"] = nvidiaKey\n\t}\n\tif geminiSessionCookies != \"\" {\n\t\tvalues[\"GEMINI_SESSION_COOKIES\"] = geminiSessionCookies\n\t}\n\tif grokSessionToken != \"\" {\n\t\tvalues[\"GROK_SESSION_TOKEN\"] = grokSessionToken\n\t}\n\tif copilotRefreshToken != \"\" {\n\t\tvalues[\"COPILOT_REFRESH_TOKEN\"] = copilotRefreshToken\n\t}\n\tif perplexitySessionToken != \"\" {\n\t\tvalues[\"PERPLEXITY_SESSION_TOKEN\"] = perplexitySessionToken\n\t}\n\tif contains(methods, methodOllamaLocal) {\n\t\tvalues[\"OLLAMA_API_BASE\"] = strings.TrimSpace(ollamaAPIBase)\n\t\tvalues[\"OLLAMA_MODEL\"] = strings.TrimSpace(ollamaModel)\n\t}\n\tif contains(methods, methodOllamaCloud) {\n\t\tvalues[\"OLLAMA_CLOUD_API_BASE\"] = strings.TrimSpace(ollamaCloudAPIBase)\n\t\tvalues[\"OLLAMA_CLOUD_API_KEY\"] = strings.TrimSpace(ollamaCloudAPIKey)\n\t\tvalues[\"OLLAMA_CLOUD_MODEL\"] = strings.TrimSpace(ollamaCloudModel)\n\t}\n\tif contains(methods, methodBedrockAPI) {\n\t\tvalues[\"AWS_ACCESS_KEY_ID\"] = strings.TrimSpace(awsAccessKeyID)\n\t\tvalues[\"AWS_SECRET_ACCESS_KEY\"] = strings.TrimSpace(awsSecretAccessKey)\n\t\t// LiteLLM's Bedrock provider reads AWS_REGION_NAME, not the\n\t\t// boto3-standard AWS_REGION. Set both for SDK compatibility.\n\t\tvalues[\"AWS_REGION_NAME\"] = strings.TrimSpace(awsRegion)\n\t\tvalues[\"AWS_REGION\"] = strings.TrimSpace(awsRegion)\n\t}\n\tif contains(methods, methodVertexAPI) {\n\t\tvalues[\"GOOGLE_APPLICATION_CREDENTIALS\"] = strings.TrimSpace(vertexCredsPath)\n\t\tvalues[\"VERTEXAI_PROJECT\"] = strings.TrimSpace(vertexProject)\n\t\tvalues[\"VERTEXAI_LOCATION\"] = strings.TrimSpace(vertexLocation)\n\t}\n\tif contains(methods, methodAzureAPI) {\n\t\tvalues[\"AZURE_API_KEY\"] = strings.TrimSpace(azureAPIKey)\n\t\tvalues[\"AZURE_API_BASE\"] = strings.TrimSpace(azureAPIBase)\n\t\tvalues[\"AZURE_API_VERSION\"] = strings.TrimSpace(azureAPIVersion)\n\t}\n\tif githubToken != \"\" {\n\t\t// LiteLLM's github/ provider reads GITHUB_API_KEY; the GitHub\n\t\t// CLI + most docs use GITHUB_TOKEN. Set both so either path\n\t\t// works without operator surprise.\n\t\tvalues[\"GITHUB_TOKEN\"] = strings.TrimSpace(githubToken)\n\t\tvalues[\"GITHUB_API_KEY\"] = strings.TrimSpace(githubToken)\n\t}\n\tif groqKey != \"\" {\n\t\tvalues[\"GROQ_API_KEY\"] = strings.TrimSpace(groqKey)\n\t}\n\tif togetherKey != \"\" {\n\t\tvalues[\"TOGETHER_API_KEY\"] = strings.TrimSpace(togetherKey)\n\t}\n\tif fireworksKey != \"\" {\n\t\tvalues[\"FIREWORKS_API_KEY\"] = strings.TrimSpace(fireworksKey)\n\t}\n\tif cohereKey != \"\" {\n\t\tvalues[\"COHERE_API_KEY\"] = strings.TrimSpace(cohereKey)\n\t}\n\tif moonshotKey != \"\" {\n\t\tvalues[\"MOONSHOT_API_KEY\"] = strings.TrimSpace(moonshotKey)\n\t}\n\tif zaiKey != \"\" {\n\t\tvalues[\"ZAI_API_KEY\"] = strings.TrimSpace(zaiKey)\n\t}\n\tif dashscopeKey != \"\" {\n\t\tvalues[\"DASHSCOPE_API_KEY\"] = strings.TrimSpace(dashscopeKey)\n\t}\n\tif contains(methods, methodLMStudioLocal) {\n\t\tvalues[\"LMSTUDIO_API_BASE\"] = strings.TrimSpace(lmStudioAPIBase)\n\t\tvalues[\"LMSTUDIO_MODEL\"] = strings.TrimSpace(lmStudioModel)\n\t\t// LM Studio accepts any string as the API key; set a sentinel\n\t\t// so LiteLLM's openai-compatible shim doesn't fail an empty\n\t\t// Authorization header.\n\t\tvalues[\"LMSTUDIO_API_KEY\"] = \"lm-studio\"\n\t}\n\tif contains(methods, methodCustomOpenAIAPI) {\n\t\tvalues[\"CUSTOM_OPENAI_API_BASE\"] = strings.TrimSpace(customOpenAIAPIBase)\n\t\tvalues[\"CUSTOM_OPENAI_API_KEY\"] = strings.TrimSpace(customOpenAIAPIKey)\n\t\tvalues[\"CUSTOM_OPENAI_MODEL\"] = strings.TrimSpace(customOpenAIModel)\n\t}\n\n\tif useLangSmith && langSmithKey != \"\" {\n\t\tvalues[\"LANGSMITH_TRACING\"] = \"true\"\n\t\tvalues[\"LANGSMITH_API_KEY\"] = langSmithKey\n\t\tvalues[\"LANGSMITH_PROJECT\"] = \"decepticon\"\n\t}\n\n\tif err := config.WriteEnvFromEmbed(config.EnvPath(), values); err != nil {\n\t\treturn fmt.Errorf(\"write .env: %w\", err)\n\t}\n\n\t// Summary\n\tfmt.Println()\n\tfmt.Println(ui.Green.Render(\" ✓ Configuration saved\"))\n\tfmt.Println()\n\tfmt.Println(ui.Dim.Render(\" ┌──────────────────────────────────┐\"))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Methods \") + ui.Dim.Render(strings.Join(priority, \", \")))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Profile \") + ui.Dim.Render(profile))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Language \") + ui.Dim.Render(language))\n\tif useLangSmith {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" LangSmith \") + ui.Green.Render(\"enabled\"))\n\t}\n\tfmt.Println(ui.Dim.Render(\" │\"))\n\tfmt.Println(ui.Dim.Render(\" │ \") + ui.Dim.Render(config.EnvPath()))\n\tfmt.Println(ui.Dim.Render(\" └──────────────────────────────────┘\"))\n\tfmt.Println()\n\n\t// Install opscontrol as a managed service (ADR-0006). Idempotent\n\t// — re-running onboard re-templates the unit so a launcher\n\t// upgrade picks up the new ExecStart path. On hosts without\n\t// systemd-user / launchd this is a no-op announcement, and the\n\t// launcher's spawn fallback takes over at `decepticon start`.\n\tfmt.Println()\n\tif err := opscontrol.EnsureInstalled(); err != nil {\n\t\t// Non-fatal: onboarding can complete without managed-service\n\t\t// mode, the user will just stay on launcher-spawn fallback.\n\t\tui.Warning(\"opscontrol install skipped: \" + err.Error())\n\t}\n\tfmt.Println()\n\n\t// One-time GitHub star ask — the natural post-onboarding moment.\n\t// Suppressed on subsequent runs by the ack file at\n\t// $DECEPTICON_HOME/.starred, so a re-run of `decepticon onboard\n\t// --reset` does not re-prompt.\n\tstarprompt.PromptIfNotStarred()\n\n\tui.DimText(\" Run 'decepticon' to start the platform\")\n\treturn nil\n}\n\nfunc contains(haystack []string, needle string) bool {\n\treturn slices.Contains(haystack, needle)\n}\n\nfunc nonEmpty(s string) error {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn fmt.Errorf(\"value is required\")\n\t}\n\treturn nil\n}\n\n// optionalClaudeOAuthToken accepts an empty value (the user falls back to the\n// on-disk credentials file) but, when provided, requires the Claude OAuth\n// token shape (`sk-ant-oat01-…`) so a mistyped API key or stray paste is\n// caught at the prompt rather than failing silently at runtime.\nfunc optionalClaudeOAuthToken(s string) error {\n\tt := strings.TrimSpace(s)\n\tif t == \"\" {\n\t\treturn nil\n\t}\n\tif !strings.HasPrefix(t, \"sk-ant-oat01-\") {\n\t\treturn fmt.Errorf(\"expected a `claude setup-token` token starting with sk-ant-oat01- (leave blank to use the credentials file)\")\n\t}\n\treturn nil\n}\n\nfunc boolStr(b bool) string {\n\tif b {\n\t\treturn \"true\"\n\t}\n\treturn \"false\"\n}\n\n// buildOllamaModelField returns the OLLAMA_MODEL form field that\n// matches the host probe outcome: strict Select when tool-capable\n// models are pulled, otherwise a remediation Note (different message\n// for the reachable-but-no-tools case vs unreachable Ollama).\nfunc buildOllamaModelField(probe ollamaProbeResult, selected *string) huh.Field {\n\tif len(probe.ToolCapableModels) > 0 {\n\t\toptions := make([]huh.Option[string], 0, len(probe.ToolCapableModels))\n\t\tfor _, m := range probe.ToolCapableModels {\n\t\t\toptions = append(options, huh.NewOption(m, m))\n\t\t}\n\t\tif !slices.Contains(probe.ToolCapableModels, *selected) {\n\t\t\t*selected = probe.ToolCapableModels[0]\n\t\t}\n\t\treturn huh.NewSelect[string]().\n\t\t\tTitle(\"OLLAMA_MODEL\").\n\t\t\tDescription(\"Tool-capable models found on your host. Decepticon\\nagents always emit tool calls — these are the only\\nmodels the wizard will accept.\").\n\t\t\tOptions(options...).\n\t\t\tValue(selected)\n\t}\n\n\tif probe.Reachable {\n\t\treturn huh.NewNote().\n\t\t\tTitle(\"OLLAMA_MODEL — no tool-capable models found\").\n\t\t\tDescription(\"Ollama is reachable but none of your pulled models\\nadvertise the 'tools' capability. Decepticon agents\\nalways emit tool calls, so a model without tool\\nsupport cannot power them.\\n\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n\t}\n\n\treturn huh.NewNote().\n\t\tTitle(\"OLLAMA_MODEL — Ollama not reachable\").\n\t\tDescription(\"Could not reach Ollama at \" + defaultOllamaAPIBase + \".\\n\\nMost likely Ollama isn't running or is bound to\\n127.0.0.1 only (which the Decepticon container can't\\nsee). Launch it on all interfaces, pull a tool-capable\\nmodel, then re-run the wizard:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n}\n\n// ollamaUnusableError surfaces the post-form remediation when the user\n// picked Ollama but the probe found nothing usable. Two flavors so the\n// hint matches whichever in-form Note was shown.\nfunc ollamaUnusableError(probe ollamaProbeResult, baseURL string) error {\n\tif probe.Reachable {\n\t\treturn fmt.Errorf(\n\t\t\t\"Ollama selected but no tool-capable models found on the host.\\n\" +\n\t\t\t\t\"Decepticon agents always emit tool calls — pull a tool-capable\\n\" +\n\t\t\t\t\"model and verify it advertises tools, then re-run:\\n\\n\" +\n\t\t\t\t\" ollama pull qwen3-coder:30b\\n\" +\n\t\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\" +\n\t\t\t\t\" decepticon onboard --reset\")\n\t}\n\treturn fmt.Errorf(\n\t\t\"Ollama selected but the host probe could not reach %s.\\n\"+\n\t\t\t\"Make sure Ollama is running and bound to all interfaces (the\\n\"+\n\t\t\t\"default 127.0.0.1 binding is invisible to containers), then\\n\"+\n\t\t\t\"pull a tool-capable model and re-run:\\n\\n\"+\n\t\t\t\" OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\"+\n\t\t\t\" ollama pull qwen3-coder:30b\\n\"+\n\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\"+\n\t\t\t\" decepticon onboard --reset\",\n\t\tbaseURL)\n}\n"}, "1badb52dcc5fa8b844bdab7864fc3aa833832bb7": {"t": 1785755389.1179402, "n": 44204, "h": "1c85991ca6c8b3ad", "c": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/huh/v2\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/cmd/opscontrol\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/platform\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/starprompt\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui\"\n\t\"github.com/spf13/cobra\"\n)\n\n// onboardOllamaProbeBudget bounds the wait for the background Ollama\n// discovery probe at form-construction time. Per-request timeout\n// (ollama_models.go) is much shorter; this is the worst-case across\n// all candidate URLs.\nconst onboardOllamaProbeBudget = 3 * time.Second\n\nvar resetFlag bool\n\nvar onboardCmd = &cobra.Command{\n\tUse: \"onboard\",\n\tShort: \"Configure Decepticon (auth methods, model profile, observability)\",\n\tRunE: runOnboard,\n}\n\nfunc init() {\n\tonboardCmd.Flags().BoolVar(&resetFlag, \"reset\", false, \"Reconfigure even if .env already exists\")\n\trootCmd.AddCommand(onboardCmd)\n}\n\n// AuthMethod identifiers — must match decepticon/llm/models.py::AuthMethod.\nconst (\n\tmethodAnthropicOAuth = \"anthropic_oauth\"\n\tmethodAnthropicAPI = \"anthropic_api\"\n\tmethodOpenAIOAuth = \"openai_oauth\"\n\tmethodOpenAIAPI = \"openai_api\"\n\tmethodGoogleOAuth = \"google_oauth\"\n\tmethodGoogleAPI = \"google_api\"\n\tmethodMiniMaxAPI = \"minimax_api\"\n\tmethodDeepSeekAPI = \"deepseek_api\"\n\tmethodXAIAPI = \"xai_api\"\n\tmethodGrokOAuth = \"grok_oauth\"\n\tmethodMistralAPI = \"mistral_api\"\n\tmethodOpenRouterAPI = \"openrouter_api\"\n\tmethodNvidiaAPI = \"nvidia_api\"\n\tmethodCopilotOAuth = \"copilot_oauth\"\n\tmethodPerplexityOAuth = \"perplexity_oauth\"\n\tmethodOllamaLocal = \"ollama_local\"\n\tmethodOllamaCloud = \"ollama_cloud\"\n\t// Cloud gateways added in the OpenClaude provider migration.\n\tmethodBedrockAPI = \"bedrock_api\"\n\tmethodVertexAPI = \"vertex_api\"\n\tmethodAzureAPI = \"azure_api\"\n\tmethodGroqAPI = \"groq_api\"\n\tmethodTogetherAPI = \"together_api\"\n\tmethodFireworksAPI = \"fireworks_api\"\n\tmethodCohereAPI = \"cohere_api\"\n\tmethodMoonshotAPI = \"moonshot_api\"\n\tmethodZaiAPI = \"zai_api\"\n\tmethodDashscopeAPI = \"dashscope_api\"\n\tmethodGitHubModelsAPI = \"github_models_api\"\n\tmethodLMStudioLocal = \"lmstudio_local\"\n\tmethodCustomOpenAIAPI = \"custom_openai_api\"\n)\n\n// Defaults shown in form placeholders for the new providers.\nconst (\n\tdefaultLMStudioAPIBase = \"http://host.docker.internal:1234/v1\"\n\tdefaultLMStudioModel = \"qwen2.5-coder-7b-instruct\"\n\tdefaultAzureAPIVersion = \"2024-08-01-preview\"\n)\n\n// Default Ollama wiring shown to OSS users. `host.docker.internal`\n// is the universal answer regardless of where Ollama runs (macOS host,\n// WSL2 distro, native Linux): from inside Decepticon's containers it\n// resolves to the host network namespace via the\n// `extra_hosts: [host.docker.internal:host-gateway]` entry on the\n// litellm service in docker-compose.yml. `localhost` is never the\n// right answer here — that's the container itself.\n//\n// The host's Ollama must additionally be bound to 0.0.0.0 (the default\n// 127.0.0.1 binding only accepts host-side connections); the wizard\n// surfaces this requirement to the user.\n//\n// The default model is the smallest/fastest one most laptops can\n// actually run; users with a GPU will pick something like qwen3-coder:30b.\nconst (\n\tdefaultOllamaAPIBase = \"http://host.docker.internal:11434\"\n\tdefaultOllamaModel = \"llama3.2\"\n)\n\n// methodOrder is the priority order surfaced in the wizard. The\n// resulting DECEPTICON_AUTH_PRIORITY preserves this order, filtered\n// to the methods the user actually selected. OAuth precedes the\n// matching API on purpose: a subscription primary should fall back\n// to the paid API only when the subscription quota is exhausted.\n// Ollama sits last: cloud providers are usually preferred when both\n// are configured, but a user wanting to lead with local-only inference\n// can reorder the priority manually in .env.\nvar methodOrder = []string{\n\tmethodAnthropicOAuth,\n\tmethodAnthropicAPI,\n\tmethodOpenAIOAuth,\n\tmethodOpenAIAPI,\n\tmethodGoogleOAuth,\n\tmethodGoogleAPI,\n\tmethodMiniMaxAPI,\n\tmethodDeepSeekAPI,\n\tmethodXAIAPI,\n\tmethodGrokOAuth,\n\tmethodMistralAPI,\n\tmethodOpenRouterAPI,\n\tmethodNvidiaAPI,\n\tmethodCopilotOAuth,\n\tmethodPerplexityOAuth,\n\t// Cloud gateways — anthropic-via-cloud paths first, then\n\t// other multi-vendor hubs.\n\tmethodBedrockAPI,\n\tmethodVertexAPI,\n\tmethodAzureAPI,\n\tmethodGitHubModelsAPI,\n\tmethodGroqAPI,\n\tmethodTogetherAPI,\n\tmethodFireworksAPI,\n\tmethodCohereAPI,\n\tmethodMoonshotAPI,\n\tmethodZaiAPI,\n\tmethodDashscopeAPI,\n\t// Local last so cloud-preferred default still picks remote\n\t// providers as primary; users can re-order in .env.\n\tmethodLMStudioLocal,\n\tmethodOllamaLocal,\n\tmethodOllamaCloud,\n\tmethodCustomOpenAIAPI,\n}\n\n// systemCheckGroup renders the host-environment snapshot as the first\n// step of onboarding. It detects OS/architecture (so the user sees the\n// machine is recognized — be it Windows, macOS, a Linux pentest distro,\n// or a Raspberry Pi) and the Docker readiness state, with OS-specific\n// remediation when something is missing. It never blocks: credentials\n// can be configured before Docker is sorted out.\nfunc systemCheckGroup(sys platform.SystemInfo) *huh.Group {\n\tvar b strings.Builder\n\n\tosLine := sys.OSLabel() + \" (\" + sys.Arch + \")\"\n\tif sys.IsWSL {\n\t\tosLine += \" · WSL2\"\n\t}\n\tb.WriteString(\"OS \" + osLine + \"\\n\")\n\n\tdocker := \"not installed\"\n\tif sys.DockerInstalled {\n\t\tdocker = \"installed\"\n\t\tif sys.DockerRunning {\n\t\t\tdocker += \" · running\"\n\t\t} else {\n\t\t\tdocker += \" · stopped\"\n\t\t}\n\t\tif sys.ComposeAvailable {\n\t\t\tdocker += \" · compose v2\"\n\t\t}\n\t}\n\tb.WriteString(\"Docker \" + docker + \"\\n\\n\")\n\n\tif sys.Ready() {\n\t\tb.WriteString(\"This system is ready to run Decepticon.\")\n\t} else {\n\t\tb.WriteString(sys.DockerHint() + \"\\n\")\n\t\tb.WriteString(\"You can still configure credentials now and sort out Docker before launching.\")\n\t}\n\n\treturn huh.NewGroup(\n\t\thuh.NewNote().\n\t\t\tTitle(\"System Check\").\n\t\t\tDescription(b.String()),\n\t)\n}\n\nfunc runOnboard(cmd *cobra.Command, args []string) error {\n\tif config.EnvExists() && !resetFlag {\n\t\tui.Info(\".env already configured at \" + config.EnvPath())\n\t\tui.DimText(\"Run 'decepticon onboard --reset' to reconfigure\")\n\t\treturn nil\n\t}\n\n\t// Kick off Ollama discovery in the background so the network\n\t// round-trip overlaps with huh's startup work; the OLLAMA_MODEL\n\t// field type depends on the result (Select vs remediation Note).\n\tprobeCh := make(chan ollamaProbeResult, 1)\n\tgo func() {\n\t\tprobeCh <- probeOllamaForOnboard(defaultOllamaAPIBase)\n\t}()\n\n\tvar (\n\t\tmethods []string\n\t\tanthropicKey string\n\t\tclaudeOAuthToken string\n\t\topenaiKey string\n\t\tgeminiKey string\n\t\tminimaxKey string\n\t\tdeepseekKey string\n\t\txaiKey string\n\t\tmistralKey string\n\t\topenrouterKey string\n\t\tnvidiaKey string\n\t\tgeminiSessionCookies string\n\t\tcopilotRefreshToken string\n\t\tgrokSessionToken string\n\t\tperplexitySessionToken string\n\t\tollamaAPIBase = defaultOllamaAPIBase\n\t\tollamaModel = defaultOllamaModel\n\t\t// Cloud gateways\n\t\tawsAccessKeyID string\n\t\tawsSecretAccessKey string\n\t\tawsRegion = \"us-east-1\"\n\t\tvertexCredsPath string\n\t\tvertexProject string\n\t\tvertexLocation = \"us-central1\"\n\t\tazureAPIKey string\n\t\tazureAPIBase string\n\t\tazureAPIVersion = defaultAzureAPIVersion\n\t\tgroqKey string\n\t\ttogetherKey string\n\t\tfireworksKey string\n\t\tcohereKey string\n\t\tmoonshotKey string\n\t\tzaiKey string\n\t\tdashscopeKey string\n\t\tgithubToken string\n\t\t// Cloud Ollama\n\t\tollamaCloudAPIBase string\n\t\tollamaCloudAPIKey string\n\t\tollamaCloudModel string\n\t\t// LM Studio (local OpenAI-compatible)\n\t\tlmStudioAPIBase = defaultLMStudioAPIBase\n\t\tlmStudioModel = defaultLMStudioModel\n\t\t// Custom OpenAI-compatible endpoint\n\t\tcustomOpenAIAPIBase string\n\t\tcustomOpenAIAPIKey string\n\t\tcustomOpenAIModel string\n\t\tprofile string\n\t\tlanguage = \"en\"\n\t\tuseLangSmith bool\n\t\tlangSmithKey string\n\t)\n\t// Block on the probe (zero-value result on timeout means\n\t// \"unreachable\" — drops through to the remediation Note).\n\t// time.NewTimer + Stop avoids the time.After timer leak when the\n\t// probe finishes first.\n\tprobeTimer := time.NewTimer(onboardOllamaProbeBudget)\n\tvar ollamaProbe ollamaProbeResult\n\tselect {\n\tcase ollamaProbe = <-probeCh:\n\t\tprobeTimer.Stop()\n\tcase <-probeTimer.C:\n\t}\n\tollamaModelField := buildOllamaModelField(ollamaProbe, &ollamaModel)\n\n\t// Probe the host environment so the wizard can confirm — up front —\n\t// that this machine (whatever OS/arch it is) can actually run the\n\t// Docker stack, and surface remediation before the user spends time\n\t// entering credentials.\n\tsysInfo := platform.Detect()\n\n\tform := huh.NewForm(\n\t\t// Step 0: system check.\n\t\tsystemCheckGroup(sysInfo),\n\n\t\t// Intro\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Decepticon Setup\").\n\t\t\t\tDescription(\"Configure auth methods, model profile, and\\nobservability.\\n\\nUse ↑↓ to navigate, space to toggle, Enter to confirm.\"),\n\t\t),\n\n\t\t// Step 1: Auth methods (multi-select)\n\t\thuh.NewGroup(\n\t\t\thuh.NewMultiSelect[string]().\n\t\t\t\tTitle(\"Auth Methods\").\n\t\t\t\tDescription(\"Press SPACE to toggle, ENTER to confirm.\\nUse ↑↓ to scroll, '/' to filter the list.\\nPick every credential you have — each is an\\nindependent fallback in priority order.\").\n\t\t\t\tFilterable(true).\n\t\t\t\tHeight(15).\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"Claude Code OAuth — Anthropic subscription (auth/*)\", methodAnthropicOAuth),\n\t\t\t\t\thuh.NewOption(\"Anthropic API Key — sk-ant-...\", methodAnthropicAPI),\n\t\t\t\t\thuh.NewOption(\"ChatGPT OAuth — ChatGPT Pro/Plus/Team subscription via `codex login` (auth/gpt-*)\", methodOpenAIOAuth),\n\t\t\t\t\thuh.NewOption(\"OpenAI API Key — sk-...\", methodOpenAIAPI),\n\t\t\t\t\thuh.NewOption(\"Google API Key — AIza... (Gemini)\", methodGoogleAPI),\n\t\t\t\t\thuh.NewOption(\"MiniMax API Key — eyJ...\", methodMiniMaxAPI),\n\t\t\t\t\thuh.NewOption(\"DeepSeek API Key — sk-...\", methodDeepSeekAPI),\n\t\t\t\t\thuh.NewOption(\"xAI API Key — xai-... (Grok)\", methodXAIAPI),\n\t\t\t\t\thuh.NewOption(\"Mistral API Key — (no fixed prefix)\", methodMistralAPI),\n\t\t\t\t\thuh.NewOption(\"OpenRouter API Key — sk-or-...\", methodOpenRouterAPI),\n\t\t\t\t\thuh.NewOption(\"Nvidia NIM API Key — nvapi-...\", methodNvidiaAPI),\n\t\t\t\t\thuh.NewOption(\"Gemini Advanced — Google One AI Premium subscription (gemini-sub/*)\", methodGoogleOAuth),\n\t\t\t\t\thuh.NewOption(\"SuperGrok — X Premium+ Grok subscription (grok-sub/*)\", methodGrokOAuth),\n\t\t\t\t\thuh.NewOption(\"GitHub Copilot Pro — Copilot subscription (copilot/*)\", methodCopilotOAuth),\n\t\t\t\t\thuh.NewOption(\"Perplexity Pro — Perplexity subscription (pplx-sub/*)\", methodPerplexityOAuth),\n\t\t\t\t\thuh.NewOption(\"AWS Bedrock — Anthropic models on AWS (bedrock/*)\", methodBedrockAPI),\n\t\t\t\t\thuh.NewOption(\"GCP Vertex AI — Anthropic + Gemini on GCP (vertex_ai/*)\", methodVertexAPI),\n\t\t\t\t\thuh.NewOption(\"Azure OpenAI — Azure-hosted GPT deployments (azure/*)\", methodAzureAPI),\n\t\t\t\t\thuh.NewOption(\"GitHub Models — GPT family via GitHub PAT (github/*)\", methodGitHubModelsAPI),\n\t\t\t\t\thuh.NewOption(\"Groq Cloud — LPU-accelerated Llama (groq/*)\", methodGroqAPI),\n\t\t\t\t\thuh.NewOption(\"Together AI — Llama / Mixtral hub (together_ai/*)\", methodTogetherAPI),\n\t\t\t\t\thuh.NewOption(\"Fireworks AI — Llama / Mixtral hub (fireworks_ai/*)\", methodFireworksAPI),\n\t\t\t\t\thuh.NewOption(\"Cohere Command — Command-A / Command-R (cohere/*)\", methodCohereAPI),\n\t\t\t\t\thuh.NewOption(\"Moonshot Kimi K2 — Kimi K2 (moonshot/*)\", methodMoonshotAPI),\n\t\t\t\t\thuh.NewOption(\"Z.ai GLM-4.5 — GLM-4.5 / GLM-4.5-Air (zai/*)\", methodZaiAPI),\n\t\t\t\t\thuh.NewOption(\"Alibaba DashScope — Qwen Max/Plus/Turbo (dashscope/*)\", methodDashscopeAPI),\n\t\t\t\t\thuh.NewOption(\"LM Studio (local) — local OpenAI-compatible server (lm_studio/*)\", methodLMStudioLocal),\n\t\t\t\t\thuh.NewOption(\"Local LLM (Ollama) — any pulled model, no API key\", methodOllamaLocal),\n\t\t\t\t\thuh.NewOption(\"Ollama Cloud — hosted Ollama (ollama_chat/* via cloud)\", methodOllamaCloud),\n\t\t\t\t\thuh.NewOption(\"Custom OpenAI Endpoint — bring-your-own OpenAI-compatible URL\", methodCustomOpenAIAPI),\n\t\t\t\t).\n\t\t\t\tValue(&methods).\n\t\t\t\tValidate(func(s []string) error {\n\t\t\t\t\tif len(s) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"select at least one credential\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"1 / 5 · Credentials\").\n\t\t\tDescription(\"Select all that apply\"),\n\n\t\t// Step 2-claude-oauth: Claude Code subscription long-lived token.\n\t\t// Run `claude setup-token` once (any machine, interactive browser\n\t\t// OAuth) → paste the 1-year token here. It is honored via the\n\t\t// ANTHROPIC_OAUTH_TOKEN env override, which carries expiresAt=0 so it\n\t\t// is never auto-refreshed — headless-safe for 24/7 / cloud, with no\n\t\t// live Claude Code session and no ~/.claude/.credentials.json mount.\n\t\t// Optional: leave blank to fall back to the rotating on-disk\n\t\t// credentials from an interactive `claude` login.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Claude Code Subscription (long-lived token)\").\n\t\t\t\tDescription(\"Run `claude setup-token` (browser OAuth, needs a\\nPro/Max/Team/Enterprise plan) → paste the 1-year\\ntoken below. Headless-safe: never refreshed, no live\\nClaude Code session needed. Or leave blank to use a\\nrotating ~/.claude/.credentials.json from `claude` login.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"ANTHROPIC_OAUTH_TOKEN\").\n\t\t\t\tPlaceholder(\"sk-ant-oat01-... (leave blank to use credentials file)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&claudeOAuthToken).\n\t\t\t\tValidate(optionalClaudeOAuthToken),\n\t\t).Title(\"2 / 5 · Claude Code Subscription\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicOAuth) }),\n\n\t\t// Step 2a: Anthropic API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Anthropic API Key\").\n\t\t\t\tPlaceholder(\"sk-ant-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&anthropicKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Anthropic API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicAPI) }),\n\n\t\t// Step 2c: OpenAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenAI API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenAI API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIAPI) }),\n\n\t\t// Step 2c: Google API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Google (Gemini) API Key\").\n\t\t\t\tPlaceholder(\"AIza...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Google API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleAPI) }),\n\n\t\t// Step 2d: MiniMax API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MiniMax API Key\").\n\t\t\t\tPlaceholder(\"eyJ...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&minimaxKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · MiniMax API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMiniMaxAPI) }),\n\n\t\t// Step 2d-i: DeepSeek API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DeepSeek API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&deepseekKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DeepSeek API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDeepSeekAPI) }),\n\n\t\t// Step 2d-ii: xAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"xAI API Key\").\n\t\t\t\tPlaceholder(\"xai-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&xaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · xAI API (Grok)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodXAIAPI) }),\n\n\t\t// Step 2d-iii: Mistral API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Mistral API Key\").\n\t\t\t\tPlaceholder(\"paste your Mistral API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&mistralKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Mistral API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMistralAPI) }),\n\n\t\t// Step 2e: OpenRouter API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenRouter API Key\").\n\t\t\t\tPlaceholder(\"sk-or-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openrouterKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenRouter API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenRouterAPI) }),\n\n\t\t// Step 2f: Nvidia NIM API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Nvidia NIM API Key\").\n\t\t\t\tPlaceholder(\"nvapi-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&nvidiaKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Nvidia NIM API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodNvidiaAPI) }),\n\n\t\t// Step 2-oauth-i: Gemini Advanced subscription\n\t\t// Multi-cookie value (NID + Secure-1PSID + Secure-1PSIDTS, etc.)\n\t\t// joined with semicolons. Optional — power users can drop a\n\t\t// tokens.json under ~/.config/gemini/ instead and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Gemini Advanced Subscription\").\n\t\t\t\tDescription(\"Open gemini.google.com → DevTools → Application →\\nCookies → gemini.google.com. Copy the values of\\nNID, __Secure-1PSID, __Secure-1PSIDTS as a single\\nsemicolon-joined string. Or leave blank to use\\nGEMINI_ACCESS_TOKEN / ~/.config/gemini/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GEMINI_SESSION_COOKIES\").\n\t\t\t\tPlaceholder(\"NID=...; __Secure-1PSID=... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiSessionCookies),\n\t\t).Title(\"2 / 5 · Gemini Advanced\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleOAuth) }),\n\n\t\t// Step 2-oauth-ii: SuperGrok subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"SuperGrok Subscription\").\n\t\t\t\tDescription(\"Open grok.com → DevTools → Application →\\nCookies → grok.com → copy the value of\\n`auth_token`. Or leave blank to use\\nGROK_ACCESS_TOKEN / ~/.config/grok/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROK_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste auth_token cookie value (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&grokSessionToken),\n\t\t).Title(\"2 / 5 · SuperGrok\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGrokOAuth) }),\n\n\t\t// Step 2-oauth-iii: GitHub Copilot Pro subscription\n\t\t// Refresh token rotation, not a single session cookie. Devs can\n\t\t// extract one from VSCode's Copilot extension config or follow\n\t\t// gh-copilot-cli onboarding. Optional via tokens.json fallback.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GitHub Copilot Pro Subscription\").\n\t\t\t\tDescription(\"Provide a Copilot refresh token (long-lived, used\\nto rotate access tokens). Or leave blank to use\\nCOPILOT_ACCESS_TOKEN / ~/.config/copilot/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COPILOT_REFRESH_TOKEN\").\n\t\t\t\tPlaceholder(\"ghu_... or ghr_... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&copilotRefreshToken),\n\t\t).Title(\"2 / 5 · Copilot Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCopilotOAuth) }),\n\n\t\t// Step 2-oauth-iv: Perplexity Pro subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Perplexity Pro Subscription\").\n\t\t\t\tDescription(\"Open perplexity.ai → DevTools → Application →\\nCookies → perplexity.ai → copy the value of\\n`next-auth.session-token`. Or leave blank to use\\nPERPLEXITY_ACCESS_TOKEN / ~/.config/perplexity/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"PERPLEXITY_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste next-auth.session-token (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&perplexitySessionToken),\n\t\t).Title(\"2 / 5 · Perplexity Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodPerplexityOAuth) }),\n\n\t\t// Step 2g: Local Ollama. The OLLAMA_MODEL field is built from\n\t\t// the host probe (buildOllamaModelField): a strict Select when\n\t\t// tool-capable models are pulled, a remediation Note otherwise.\n\t\t// The post-form gate refuses to write .env in the Note cases.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Local Ollama\").\n\t\t\t\tDescription(\"Ollama must already be running on your host with\\nat least one tool-capable model pulled. Launch it\\nbound to all interfaces so the Decepticon container\\ncan reach it:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\\nThe default 127.0.0.1 binding only accepts host-side\\nconnections — containers won't see it.\\n\\nThe default URL `host.docker.internal:11434` works\\non macOS, Linux, and WSL2 (with or without Docker\\nDesktop). Only change it for remote / custom setups.\\nNote: the model list is probed against the default URL\\nat wizard start; if you customize OLLAMA_API_BASE,\\nfinish the wizard then re-run 'decepticon onboard\\n--reset' so the probe targets the new endpoint.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_API_BASE\").\n\t\t\t\tPlaceholder(defaultOllamaAPIBase).\n\t\t\t\tValue(&ollamaAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\tollamaModelField,\n\t\t).Title(\"2 / 5 · Local LLM (Ollama)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaLocal) }),\n\n\t\t// Step 2-cloud-i: AWS Bedrock — three-field group (key + secret + region)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"AWS Bedrock\").\n\t\t\t\tDescription(\"Bedrock uses AWS SigV4 — provide IAM access key,\\nsecret, and the region where Anthropic models are\\nenabled (us-east-1 or us-west-2 typically).\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_ACCESS_KEY_ID\").\n\t\t\t\tPlaceholder(\"AKIA...\").\n\t\t\t\tValue(&awsAccessKeyID).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_SECRET_ACCESS_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&awsSecretAccessKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_REGION\").\n\t\t\t\tPlaceholder(\"us-east-1\").\n\t\t\t\tValue(&awsRegion).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · AWS Bedrock\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodBedrockAPI) }),\n\n\t\t// Step 2-cloud-ii: GCP Vertex AI — service-account JSON path + project + region\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GCP Vertex AI\").\n\t\t\t\tDescription(\"Vertex AI uses a Google Cloud service-account JSON.\\nDownload one from IAM & Admin → Service Accounts and\\npaste the absolute file path. Project + region must\\nmatch where you enabled Anthropic + Gemini models.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GOOGLE_APPLICATION_CREDENTIALS\").\n\t\t\t\tPlaceholder(\"/abs/path/to/service-account.json\").\n\t\t\t\tValue(&vertexCredsPath).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"VERTEXAI_PROJECT\").\n\t\t\t\tPlaceholder(\"my-gcp-project-id\").\n\t\t\t\tValue(&vertexProject).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"VERTEXAI_LOCATION\").\n\t\t\t\tPlaceholder(\"us-central1\").\n\t\t\t\tValue(&vertexLocation).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · GCP Vertex AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodVertexAPI) }),\n\n\t\t// Step 2-cloud-iii: Azure OpenAI — key + endpoint + version\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Azure OpenAI Service\").\n\t\t\t\tDescription(\"Endpoint is your azure-openai resource URL,\\ne.g. https://my-resource.openai.azure.com.\\nThe deployment names map 1:1 to gpt-5.5/gpt-5.4/...\\nso the model matrix in DF resolves out of the box.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&azureAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_BASE\").\n\t\t\t\tPlaceholder(\"https://<resource>.openai.azure.com\").\n\t\t\t\tValue(&azureAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_VERSION\").\n\t\t\t\tPlaceholder(defaultAzureAPIVersion).\n\t\t\t\tValue(&azureAPIVersion).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Azure OpenAI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAzureAPI) }),\n\n\t\t// Step 2-cloud-iv: GitHub Models — PAT\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GITHUB_TOKEN\").\n\t\t\t\tDescription(\"Fine-grained PAT with the 'models' permission scope.\\nGenerate at github.com/settings/personal-access-tokens.\").\n\t\t\t\tPlaceholder(\"github_pat_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&githubToken).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · GitHub Models\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGitHubModelsAPI) }),\n\n\t\t// Step 2-cloud-v: Groq\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROQ_API_KEY\").\n\t\t\t\tPlaceholder(\"gsk_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&groqKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Groq Cloud\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGroqAPI) }),\n\n\t\t// Step 2-cloud-vi: Together AI\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"TOGETHER_API_KEY\").\n\t\t\t\tPlaceholder(\"paste your Together AI API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&togetherKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Together AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodTogetherAPI) }),\n\n\t\t// Step 2-cloud-vii: Fireworks AI\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"FIREWORKS_API_KEY\").\n\t\t\t\tPlaceholder(\"fw_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&fireworksKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Fireworks AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodFireworksAPI) }),\n\n\t\t// Step 2-cloud-viii: Cohere\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COHERE_API_KEY\").\n\t\t\t\tPlaceholder(\"paste your Cohere API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&cohereKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Cohere\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCohereAPI) }),\n\n\t\t// Step 2-cloud-ix: Moonshot Kimi K2\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MOONSHOT_API_KEY\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&moonshotKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Moonshot Kimi K2\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMoonshotAPI) }),\n\n\t\t// Step 2-cloud-x: Z.ai GLM-4.5\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"ZAI_API_KEY\").\n\t\t\t\tDescription(\"Z.ai is OpenAI-compatible — base URL is hard-wired to\\nhttps://api.z.ai/api/paas/v4. Get a key at z.ai/manage.\").\n\t\t\t\tPlaceholder(\"paste your Z.ai API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&zaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Z.ai GLM-4.5\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodZaiAPI) }),\n\n\t\t// Step 2-cloud-xi: Alibaba DashScope (Qwen)\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DASHSCOPE_API_KEY\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&dashscopeKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DashScope (Qwen)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDashscopeAPI) }),\n\n\t\t// Step 2-local-ii: LM Studio (local OpenAI-compatible)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"LM Studio\").\n\t\t\t\tDescription(\"LM Studio runs a local OpenAI-compatible server.\\nLaunch the LM Studio app and start the server\\n(Developer tab → Start). The default port is 1234.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LMSTUDIO_API_BASE\").\n\t\t\t\tPlaceholder(defaultLMStudioAPIBase).\n\t\t\t\tValue(&lmStudioAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LMSTUDIO_MODEL\").\n\t\t\t\tDescription(\"Model identifier as shown in LM Studio (e.g.\\nqwen2.5-coder-7b-instruct).\").\n\t\t\t\tPlaceholder(defaultLMStudioModel).\n\t\t\t\tValue(&lmStudioModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · LM Studio\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodLMStudioLocal) }),\n\n\t\t// Step 2-local-iii: Ollama Cloud (hosted)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Ollama Cloud\").\n\t\t\t\tDescription(\"Hosted Ollama — same /api/chat tool-calling endpoint\\nas local Ollama, just behind an API key.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_API_BASE\").\n\t\t\t\tPlaceholder(\"https://api.ollama.com/v1\").\n\t\t\t\tValue(&ollamaCloudAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&ollamaCloudAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_MODEL\").\n\t\t\t\tPlaceholder(\"llama3.3:70b\").\n\t\t\t\tValue(&ollamaCloudModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Ollama Cloud\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaCloud) }),\n\n\t\t// Step 2-custom: Custom OpenAI-compatible endpoint\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Custom OpenAI-compatible Endpoint\").\n\t\t\t\tDescription(\"Point at any OpenAI-compatible server (gateways,\\nself-hosted vLLM, internal LiteLLM, etc.). The model\\nname is sent verbatim to the upstream — match what\\nyour gateway exposes.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_API_BASE\").\n\t\t\t\tPlaceholder(\"https://gateway.example.com/v1\").\n\t\t\t\tValue(&customOpenAIAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&customOpenAIAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_MODEL\").\n\t\t\t\tPlaceholder(\"gpt-4o-mini\").\n\t\t\t\tValue(&customOpenAIModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Custom OpenAI Endpoint\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCustomOpenAIAPI) }),\n\n\t\t// Step 3: Model profile\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Model Profile\").\n\t\t\t\tDescription(\"eco per-agent tier (recommended)\\nmax every agent on HIGH (expensive)\\ntest every agent on LOW (development)\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"eco — per-agent tier (recommended)\", \"eco\"),\n\t\t\t\t\thuh.NewOption(\"max — every agent on HIGH (expensive)\", \"max\"),\n\t\t\t\t\thuh.NewOption(\"test — every agent on LOW (development)\", \"test\"),\n\t\t\t\t).\n\t\t\t\tValue(&profile),\n\t\t).Title(\"3 / 5 · Profile\"),\n\n\t\t// Step 4: Language\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Agent Language\").\n\t\t\t\tDescription(\"Language for all agent prose output (menus, questions,\\nsummaries, errors). Technical output stays in English.\\nCountry codes (dk, se, jp, cn) are auto-resolved.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"en — English (default)\", \"en\"),\n\t\t\t\t\thuh.NewOption(\"no — Norwegian\", \"no\"),\n\t\t\t\t\thuh.NewOption(\"da — Danish\", \"da\"),\n\t\t\t\t\thuh.NewOption(\"sv — Swedish\", \"sv\"),\n\t\t\t\t\thuh.NewOption(\"fi — Finnish\", \"fi\"),\n\t\t\t\t\thuh.NewOption(\"is — Icelandic\", \"is\"),\n\t\t\t\t\thuh.NewOption(\"ko — Korean\", \"ko\"),\n\t\t\t\t\thuh.NewOption(\"ja — Japanese\", \"ja\"),\n\t\t\t\t\thuh.NewOption(\"zh — Chinese\", \"zh\"),\n\t\t\t\t\thuh.NewOption(\"zh-tw — Traditional Chinese\", \"zh-tw\"),\n\t\t\t\t\thuh.NewOption(\"es — Spanish\", \"es\"),\n\t\t\t\t\thuh.NewOption(\"pt — Portuguese\", \"pt\"),\n\t\t\t\t\thuh.NewOption(\"pt-br — Brazilian Portuguese\", \"pt-br\"),\n\t\t\t\t\thuh.NewOption(\"de — German\", \"de\"),\n\t\t\t\t\thuh.NewOption(\"fr — French\", \"fr\"),\n\t\t\t\t\thuh.NewOption(\"nl — Dutch\", \"nl\"),\n\t\t\t\t\thuh.NewOption(\"it — Italian\", \"it\"),\n\t\t\t\t\thuh.NewOption(\"pl — Polish\", \"pl\"),\n\t\t\t\t\thuh.NewOption(\"cs — Czech\", \"cs\"),\n\t\t\t\t\thuh.NewOption(\"uk — Ukrainian\", \"uk\"),\n\t\t\t\t\thuh.NewOption(\"ro — Romanian\", \"ro\"),\n\t\t\t\t\thuh.NewOption(\"hr — Croatian\", \"hr\"),\n\t\t\t\t\thuh.NewOption(\"bg — Bulgarian\", \"bg\"),\n\t\t\t\t\thuh.NewOption(\"ru — Russian\", \"ru\"),\n\t\t\t\t\thuh.NewOption(\"el — Greek\", \"el\"),\n\t\t\t\t\thuh.NewOption(\"hu — Hungarian\", \"hu\"),\n\t\t\t\t\thuh.NewOption(\"tr — Turkish\", \"tr\"),\n\t\t\t\t\thuh.NewOption(\"ar — Arabic\", \"ar\"),\n\t\t\t\t\thuh.NewOption(\"fa — Persian\", \"fa\"),\n\t\t\t\t\thuh.NewOption(\"he — Hebrew\", \"he\"),\n\t\t\t\t\thuh.NewOption(\"hi — Hindi\", \"hi\"),\n\t\t\t\t\thuh.NewOption(\"th — Thai\", \"th\"),\n\t\t\t\t\thuh.NewOption(\"vi — Vietnamese\", \"vi\"),\n\t\t\t\t\thuh.NewOption(\"id — Indonesian\", \"id\"),\n\t\t\t\t\thuh.NewOption(\"ms — Malay\", \"ms\"),\n\t\t\t\t\thuh.NewOption(\"tl — Filipino\", \"tl\"),\n\t\t\t\t\thuh.NewOption(\"sw — Swahili\", \"sw\"),\n\t\t\t\t\thuh.NewOption(\"af — Afrikaans\", \"af\"),\n\t\t\t\t\thuh.NewOption(\"wenyan — 文言文 + English technical terms\", \"wenyan\"),\n\t\t\t\t).\n\t\t\t\tValue(&language),\n\t\t).Title(\"4 / 5 · Language\"),\n\n\t\t// Step 5a: LangSmith toggle\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Enable LangSmith?\").\n\t\t\t\tDescription(\"LLM observability and trace collection\").\n\t\t\t\tAffirmative(\"Yes\").\n\t\t\t\tNegative(\"No\").\n\t\t\t\tValue(&useLangSmith),\n\t\t).Title(\"5 / 5 · Observability\"),\n\n\t\t// Step 5b: LangSmith key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LangSmith API Key\").\n\t\t\t\tPlaceholder(\"lsv2_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&langSmithKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"5 / 5 · LangSmith\").\n\t\t\tWithHideFunc(func() bool { return !useLangSmith }),\n\t).WithTheme(huh.ThemeFunc(ui.DecepticonTheme))\n\n\tif err := form.Run(); err != nil {\n\t\treturn fmt.Errorf(\"setup cancelled: %w\", err)\n\t}\n\n\t// Strict-mode gate: refuse to write .env when the user picked\n\t// Ollama but the host probe found no tool-capable model. The\n\t// in-form Note shows the same remediation; this is the boundary\n\t// guarantee that a broken setup never ships.\n\tif contains(methods, methodOllamaLocal) && len(ollamaProbe.ToolCapableModels) == 0 {\n\t\treturn ollamaUnusableError(ollamaProbe, ollamaAPIBase)\n\t}\n\n\t// huh.MultiSelect returns selected values in option order, not the\n\t// order the user toggled. Re-derive the priority by walking\n\t// methodOrder and keeping only what the user picked.\n\tpriority := make([]string, 0, len(methods))\n\tfor _, m := range methodOrder {\n\t\tif contains(methods, m) {\n\t\t\tpriority = append(priority, m)\n\t\t}\n\t}\n\n\tvalues := map[string]string{\n\t\t\"DECEPTICON_MODEL_PROFILE\": profile,\n\t\t\"DECEPTICON_LANGUAGE\": language,\n\t\t\"DECEPTICON_AUTH_PRIORITY\": strings.Join(priority, \",\"),\n\t\t\"DECEPTICON_AUTH_CLAUDE_CODE\": boolStr(contains(methods, methodAnthropicOAuth)),\n\t\t\"DECEPTICON_AUTH_CHATGPT\": boolStr(contains(methods, methodOpenAIOAuth)),\n\t\t\"DECEPTICON_AUTH_GEMINI\": boolStr(contains(methods, methodGoogleOAuth)),\n\t\t\"DECEPTICON_AUTH_GROK\": boolStr(contains(methods, methodGrokOAuth)),\n\t\t\"DECEPTICON_AUTH_COPILOT\": boolStr(contains(methods, methodCopilotOAuth)),\n\t\t\"DECEPTICON_AUTH_PERPLEXITY\": boolStr(contains(methods, methodPerplexityOAuth)),\n\t}\n\n\tif anthropicKey != \"\" {\n\t\tvalues[\"ANTHROPIC_API_KEY\"] = anthropicKey\n\t}\n\tif claudeOAuthToken != \"\" {\n\t\t// Long-lived `claude setup-token`. The claude_code handler reads this\n\t\t// as a synthetic credential (expiresAt=0 → never refreshed); no\n\t\t// credentials-file mount or live CC session needed.\n\t\tvalues[\"ANTHROPIC_OAUTH_TOKEN\"] = strings.TrimSpace(claudeOAuthToken)\n\t}\n\tif openaiKey != \"\" {\n\t\tvalues[\"OPENAI_API_KEY\"] = openaiKey\n\t}\n\tif geminiKey != \"\" {\n\t\tvalues[\"GEMINI_API_KEY\"] = geminiKey\n\t}\n\tif minimaxKey != \"\" {\n\t\tvalues[\"MINIMAX_API_KEY\"] = minimaxKey\n\t}\n\tif deepseekKey != \"\" {\n\t\tvalues[\"DEEPSEEK_API_KEY\"] = deepseekKey\n\t}\n\tif xaiKey != \"\" {\n\t\tvalues[\"XAI_API_KEY\"] = xaiKey\n\t}\n\tif mistralKey != \"\" {\n\t\tvalues[\"MISTRAL_API_KEY\"] = mistralKey\n\t}\n\tif openrouterKey != \"\" {\n\t\tvalues[\"OPENROUTER_API_KEY\"] = openrouterKey\n\t}\n\tif nvidiaKey != \"\" {\n\t\tvalues[\"NVIDIA_API_KEY\"] = nvidiaKey\n\t}\n\tif geminiSessionCookies != \"\" {\n\t\tvalues[\"GEMINI_SESSION_COOKIES\"] = geminiSessionCookies\n\t}\n\tif grokSessionToken != \"\" {\n\t\tvalues[\"GROK_SESSION_TOKEN\"] = grokSessionToken\n\t}\n\tif copilotRefreshToken != \"\" {\n\t\tvalues[\"COPILOT_REFRESH_TOKEN\"] = copilotRefreshToken\n\t}\n\tif perplexitySessionToken != \"\" {\n\t\tvalues[\"PERPLEXITY_SESSION_TOKEN\"] = perplexitySessionToken\n\t}\n\tif contains(methods, methodOllamaLocal) {\n\t\tvalues[\"OLLAMA_API_BASE\"] = strings.TrimSpace(ollamaAPIBase)\n\t\tvalues[\"OLLAMA_MODEL\"] = strings.TrimSpace(ollamaModel)\n\t}\n\tif contains(methods, methodOllamaCloud) {\n\t\tvalues[\"OLLAMA_CLOUD_API_BASE\"] = strings.TrimSpace(ollamaCloudAPIBase)\n\t\tvalues[\"OLLAMA_CLOUD_API_KEY\"] = strings.TrimSpace(ollamaCloudAPIKey)\n\t\tvalues[\"OLLAMA_CLOUD_MODEL\"] = strings.TrimSpace(ollamaCloudModel)\n\t}\n\tif contains(methods, methodBedrockAPI) {\n\t\tvalues[\"AWS_ACCESS_KEY_ID\"] = strings.TrimSpace(awsAccessKeyID)\n\t\tvalues[\"AWS_SECRET_ACCESS_KEY\"] = strings.TrimSpace(awsSecretAccessKey)\n\t\t// LiteLLM's Bedrock provider reads AWS_REGION_NAME, not the\n\t\t// boto3-standard AWS_REGION. Set both for SDK compatibility.\n\t\tvalues[\"AWS_REGION_NAME\"] = strings.TrimSpace(awsRegion)\n\t\tvalues[\"AWS_REGION\"] = strings.TrimSpace(awsRegion)\n\t}\n\tif contains(methods, methodVertexAPI) {\n\t\tvalues[\"GOOGLE_APPLICATION_CREDENTIALS\"] = strings.TrimSpace(vertexCredsPath)\n\t\tvalues[\"VERTEXAI_PROJECT\"] = strings.TrimSpace(vertexProject)\n\t\tvalues[\"VERTEXAI_LOCATION\"] = strings.TrimSpace(vertexLocation)\n\t}\n\tif contains(methods, methodAzureAPI) {\n\t\tvalues[\"AZURE_API_KEY\"] = strings.TrimSpace(azureAPIKey)\n\t\tvalues[\"AZURE_API_BASE\"] = strings.TrimSpace(azureAPIBase)\n\t\tvalues[\"AZURE_API_VERSION\"] = strings.TrimSpace(azureAPIVersion)\n\t}\n\tif githubToken != \"\" {\n\t\t// LiteLLM's github/ provider reads GITHUB_API_KEY; the GitHub\n\t\t// CLI + most docs use GITHUB_TOKEN. Set both so either path\n\t\t// works without operator surprise.\n\t\tvalues[\"GITHUB_TOKEN\"] = strings.TrimSpace(githubToken)\n\t\tvalues[\"GITHUB_API_KEY\"] = strings.TrimSpace(githubToken)\n\t}\n\tif groqKey != \"\" {\n\t\tvalues[\"GROQ_API_KEY\"] = strings.TrimSpace(groqKey)\n\t}\n\tif togetherKey != \"\" {\n\t\tvalues[\"TOGETHER_API_KEY\"] = strings.TrimSpace(togetherKey)\n\t}\n\tif fireworksKey != \"\" {\n\t\tvalues[\"FIREWORKS_API_KEY\"] = strings.TrimSpace(fireworksKey)\n\t}\n\tif cohereKey != \"\" {\n\t\tvalues[\"COHERE_API_KEY\"] = strings.TrimSpace(cohereKey)\n\t}\n\tif moonshotKey != \"\" {\n\t\tvalues[\"MOONSHOT_API_KEY\"] = strings.TrimSpace(moonshotKey)\n\t}\n\tif zaiKey != \"\" {\n\t\tvalues[\"ZAI_API_KEY\"] = strings.TrimSpace(zaiKey)\n\t}\n\tif dashscopeKey != \"\" {\n\t\tvalues[\"DASHSCOPE_API_KEY\"] = strings.TrimSpace(dashscopeKey)\n\t}\n\tif contains(methods, methodLMStudioLocal) {\n\t\tvalues[\"LMSTUDIO_API_BASE\"] = strings.TrimSpace(lmStudioAPIBase)\n\t\tvalues[\"LMSTUDIO_MODEL\"] = strings.TrimSpace(lmStudioModel)\n\t\t// LM Studio accepts any string as the API key; set a sentinel\n\t\t// so LiteLLM's openai-compatible shim doesn't fail an empty\n\t\t// Authorization header.\n\t\tvalues[\"LMSTUDIO_API_KEY\"] = \"lm-studio\"\n\t}\n\tif contains(methods, methodCustomOpenAIAPI) {\n\t\tvalues[\"CUSTOM_OPENAI_API_BASE\"] = strings.TrimSpace(customOpenAIAPIBase)\n\t\tvalues[\"CUSTOM_OPENAI_API_KEY\"] = strings.TrimSpace(customOpenAIAPIKey)\n\t\tvalues[\"CUSTOM_OPENAI_MODEL\"] = strings.TrimSpace(customOpenAIModel)\n\t}\n\n\tif useLangSmith && langSmithKey != \"\" {\n\t\tvalues[\"LANGSMITH_TRACING\"] = \"true\"\n\t\tvalues[\"LANGSMITH_API_KEY\"] = langSmithKey\n\t\tvalues[\"LANGSMITH_PROJECT\"] = \"decepticon\"\n\t}\n\n\tif err := config.WriteEnvFromEmbed(config.EnvPath(), values); err != nil {\n\t\treturn fmt.Errorf(\"write .env: %w\", err)\n\t}\n\n\t// Summary\n\tfmt.Println()\n\tfmt.Println(ui.Green.Render(\" ✓ Configuration saved\"))\n\tfmt.Println()\n\tfmt.Println(ui.Dim.Render(\" ┌──────────────────────────────────┐\"))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Methods \") + ui.Dim.Render(strings.Join(priority, \", \")))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Profile \") + ui.Dim.Render(profile))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Language \") + ui.Dim.Render(language))\n\tif useLangSmith {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" LangSmith \") + ui.Green.Render(\"enabled\"))\n\t}\n\tfmt.Println(ui.Dim.Render(\" │\"))\n\tfmt.Println(ui.Dim.Render(\" │ \") + ui.Dim.Render(config.EnvPath()))\n\tfmt.Println(ui.Dim.Render(\" └──────────────────────────────────┘\"))\n\tfmt.Println()\n\n\t// Install opscontrol as a managed service (ADR-0006). Idempotent\n\t// — re-running onboard re-templates the unit so a launcher\n\t// upgrade picks up the new ExecStart path. On hosts without\n\t// systemd-user / launchd this is a no-op announcement, and the\n\t// launcher's spawn fallback takes over at `decepticon start`.\n\tfmt.Println()\n\tif err := opscontrol.EnsureInstalled(); err != nil {\n\t\t// Non-fatal: onboarding can complete without managed-service\n\t\t// mode, the user will just stay on launcher-spawn fallback.\n\t\tui.Warning(\"opscontrol install skipped: \" + err.Error())\n\t}\n\tfmt.Println()\n\n\t// One-time GitHub star ask — the natural post-onboarding moment.\n\t// Suppressed on subsequent runs by the ack file at\n\t// $DECEPTICON_HOME/.starred, so a re-run of `decepticon onboard\n\t// --reset` does not re-prompt.\n\tstarprompt.PromptIfNotStarred()\n\n\tui.DimText(\" Run 'decepticon' to start the platform\")\n\treturn nil\n}\n\nfunc contains(haystack []string, needle string) bool {\n\treturn slices.Contains(haystack, needle)\n}\n\nfunc nonEmpty(s string) error {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn fmt.Errorf(\"value is required\")\n\t}\n\treturn nil\n}\n\n// optionalClaudeOAuthToken accepts an empty value (the user falls back to the\n// on-disk credentials file) but, when provided, requires the Claude OAuth\n// token shape (`sk-ant-oat01-…`) so a mistyped API key or stray paste is\n// caught at the prompt rather than failing silently at runtime.\nfunc optionalClaudeOAuthToken(s string) error {\n\tt := strings.TrimSpace(s)\n\tif t == \"\" {\n\t\treturn nil\n\t}\n\tif !strings.HasPrefix(t, \"sk-ant-oat01-\") {\n\t\treturn fmt.Errorf(\"expected a `claude setup-token` token starting with sk-ant-oat01- (leave blank to use the credentials file)\")\n\t}\n\treturn nil\n}\n\nfunc boolStr(b bool) string {\n\tif b {\n\t\treturn \"true\"\n\t}\n\treturn \"false\"\n}\n\n// buildOllamaModelField returns the OLLAMA_MODEL form field that\n// matches the host probe outcome: strict Select when tool-capable\n// models are pulled, otherwise a remediation Note (different message\n// for the reachable-but-no-tools case vs unreachable Ollama).\nfunc buildOllamaModelField(probe ollamaProbeResult, selected *string) huh.Field {\n\tif len(probe.ToolCapableModels) > 0 {\n\t\toptions := make([]huh.Option[string], 0, len(probe.ToolCapableModels))\n\t\tfor _, m := range probe.ToolCapableModels {\n\t\t\toptions = append(options, huh.NewOption(m, m))\n\t\t}\n\t\tif !slices.Contains(probe.ToolCapableModels, *selected) {\n\t\t\t*selected = probe.ToolCapableModels[0]\n\t\t}\n\t\treturn huh.NewSelect[string]().\n\t\t\tTitle(\"OLLAMA_MODEL\").\n\t\t\tDescription(\"Tool-capable models found on your host. Decepticon\\nagents always emit tool calls — these are the only\\nmodels the wizard will accept.\").\n\t\t\tOptions(options...).\n\t\t\tValue(selected)\n\t}\n\n\tif probe.Reachable {\n\t\treturn huh.NewNote().\n\t\t\tTitle(\"OLLAMA_MODEL — no tool-capable models found\").\n\t\t\tDescription(\"Ollama is reachable but none of your pulled models\\nadvertise the 'tools' capability. Decepticon agents\\nalways emit tool calls, so a model without tool\\nsupport cannot power them.\\n\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n\t}\n\n\treturn huh.NewNote().\n\t\tTitle(\"OLLAMA_MODEL — Ollama not reachable\").\n\t\tDescription(\"Could not reach Ollama at \" + defaultOllamaAPIBase + \".\\n\\nMost likely Ollama isn't running or is bound to\\n127.0.0.1 only (which the Decepticon container can't\\nsee). Launch it on all interfaces, pull a tool-capable\\nmodel, then re-run the wizard:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n}\n\n// ollamaUnusableError surfaces the post-form remediation when the user\n// picked Ollama but the probe found nothing usable. Two flavors so the\n// hint matches whichever in-form Note was shown.\nfunc ollamaUnusableError(probe ollamaProbeResult, baseURL string) error {\n\tif probe.Reachable {\n\t\treturn fmt.Errorf(\n\t\t\t\"Ollama selected but no tool-capable models found on the host.\\n\" +\n\t\t\t\t\"Decepticon agents always emit tool calls — pull a tool-capable\\n\" +\n\t\t\t\t\"model and verify it advertises tools, then re-run:\\n\\n\" +\n\t\t\t\t\" ollama pull qwen3-coder:30b\\n\" +\n\t\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\" +\n\t\t\t\t\" decepticon onboard --reset\")\n\t}\n\treturn fmt.Errorf(\n\t\t\"Ollama selected but the host probe could not reach %s.\\n\"+\n\t\t\t\"Make sure Ollama is running and bound to all interfaces (the\\n\"+\n\t\t\t\"default 127.0.0.1 binding is invisible to containers), then\\n\"+\n\t\t\t\"pull a tool-capable model and re-run:\\n\\n\"+\n\t\t\t\" OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\"+\n\t\t\t\" ollama pull qwen3-coder:30b\\n\"+\n\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\"+\n\t\t\t\" decepticon onboard --reset\",\n\t\tbaseURL)\n}\n"}, "dd8eab12b103b89c30b4cd78391e75c2d11ff20b": {"t": 1785755391.3635397, "n": 41998, "h": "b89de8743c070541", "c": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/huh/v2\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/cmd/opscontrol\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/platform\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/starprompt\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui\"\n\t\"github.com/spf13/cobra\"\n)\n\n// onboardOllamaProbeBudget bounds the wait for the background Ollama\n// discovery probe at form-construction time. Per-request timeout\n// (ollama_models.go) is much shorter; this is the worst-case across\n// all candidate URLs.\nconst onboardOllamaProbeBudget = 3 * time.Second\n\nvar resetFlag bool\n\nvar onboardCmd = &cobra.Command{\n\tUse: \"onboard\",\n\tShort: \"Configure Decepticon (auth methods, model profile, observability)\",\n\tRunE: runOnboard,\n}\n\nfunc init() {\n\tonboardCmd.Flags().BoolVar(&resetFlag, \"reset\", false, \"Reconfigure even if .env already exists\")\n\trootCmd.AddCommand(onboardCmd)\n}\n\n// AuthMethod identifiers — must match decepticon/llm/models.py::AuthMethod.\nconst (\n\tmethodAnthropicOAuth = \"anthropic_oauth\"\n\tmethodAnthropicAPI = \"anthropic_api\"\n\tmethodOpenAIOAuth = \"openai_oauth\"\n\tmethodOpenAIAPI = \"openai_api\"\n\tmethodGoogleOAuth = \"google_oauth\"\n\tmethodGoogleAPI = \"google_api\"\n\tmethodMiniMaxAPI = \"minimax_api\"\n\tmethodDeepSeekAPI = \"deepseek_api\"\n\tmethodXAIAPI = \"xai_api\"\n\tmethodGrokOAuth = \"grok_oauth\"\n\tmethodMistralAPI = \"mistral_api\"\n\tmethodOpenRouterAPI = \"openrouter_api\"\n\tmethodNvidiaAPI = \"nvidia_api\"\n\tmethodCopilotOAuth = \"copilot_oauth\"\n\tmethodPerplexityOAuth = \"perplexity_oauth\"\n\tmethodOllamaLocal = \"ollama_local\"\n\tmethodOllamaCloud = \"ollama_cloud\"\n\t// Cloud gateways added in the OpenClaude provider migration.\n\tmethodBedrockAPI = \"bedrock_api\"\n\tmethodVertexAPI = \"vertex_api\"\n\tmethodAzureAPI = \"azure_api\"\n\tmethodGroqAPI = \"groq_api\"\n\tmethodTogetherAPI = \"together_api\"\n\tmethodFireworksAPI = \"fireworks_api\"\n\tmethodCohereAPI = \"cohere_api\"\n\tmethodMoonshotAPI = \"moonshot_api\"\n\tmethodZaiAPI = \"zai_api\"\n\tmethodDashscopeAPI = \"dashscope_api\"\n\tmethodGitHubModelsAPI = \"github_models_api\"\n\tmethodLMStudioLocal = \"lmstudio_local\"\n\tmethodCustomOpenAIAPI = \"custom_openai_api\"\n)\n\n// Defaults shown in form placeholders for the new providers.\nconst (\n\tdefaultLMStudioAPIBase = \"http://host.docker.internal:1234/v1\"\n\tdefaultLMStudioModel = \"qwen2.5-coder-7b-instruct\"\n\tdefaultAzureAPIVersion = \"2024-08-01-preview\"\n)\n\n// Default Ollama wiring shown to OSS users. `host.docker.internal`\n// is the universal answer regardless of where Ollama runs (macOS host,\n// WSL2 distro, native Linux): from inside Decepticon's containers it\n// resolves to the host network namespace via the\n// `extra_hosts: [host.docker.internal:host-gateway]` entry on the\n// litellm service in docker-compose.yml. `localhost` is never the\n// right answer here — that's the container itself.\n//\n// The host's Ollama must additionally be bound to 0.0.0.0 (the default\n// 127.0.0.1 binding only accepts host-side connections); the wizard\n// surfaces this requirement to the user.\n//\n// The default model is the smallest/fastest one most laptops can\n// actually run; users with a GPU will pick something like qwen3-coder:30b.\nconst (\n\tdefaultOllamaAPIBase = \"http://host.docker.internal:11434\"\n\tdefaultOllamaModel = \"llama3.2\"\n)\n\n// methodOrder is the priority order surfaced in the wizard. The\n// resulting DECEPTICON_AUTH_PRIORITY preserves this order, filtered\n// to the methods the user actually selected. OAuth precedes the\n// matching API on purpose: a subscription primary should fall back\n// to the paid API only when the subscription quota is exhausted.\n// Ollama sits last: cloud providers are usually preferred when both\n// are configured, but a user wanting to lead with local-only inference\n// can reorder the priority manually in .env.\nvar methodOrder = []string{\n\tmethodAnthropicOAuth,\n\tmethodAnthropicAPI,\n\tmethodOpenAIOAuth,\n\tmethodOpenAIAPI,\n\tmethodGoogleOAuth,\n\tmethodGoogleAPI,\n\tmethodMiniMaxAPI,\n\tmethodDeepSeekAPI,\n\tmethodXAIAPI,\n\tmethodGrokOAuth,\n\tmethodMistralAPI,\n\tmethodOpenRouterAPI,\n\tmethodNvidiaAPI,\n\tmethodCopilotOAuth,\n\tmethodPerplexityOAuth,\n\t// Cloud gateways — anthropic-via-cloud paths first, then\n\t// other multi-vendor hubs.\n\tmethodBedrockAPI,\n\tmethodVertexAPI,\n\tmethodAzureAPI,\n\tmethodGitHubModelsAPI,\n\tmethodGroqAPI,\n\tmethodTogetherAPI,\n\tmethodFireworksAPI,\n\tmethodCohereAPI,\n\tmethodMoonshotAPI,\n\tmethodZaiAPI,\n\tmethodDashscopeAPI,\n\t// Local last so cloud-preferred default still picks remote\n\t// providers as primary; users can re-order in .env.\n\tmethodLMStudioLocal,\n\tmethodOllamaLocal,\n\tmethodOllamaCloud,\n\tmethodCustomOpenAIAPI,\n}\n\n// systemCheckGroup renders the host-environment snapshot as the first\n// step of onboarding. It detects OS/architecture (so the user sees the\n// machine is recognized — be it Windows, macOS, a Linux pentest distro,\n// or a Raspberry Pi) and the Docker readiness state, with OS-specific\n// remediation when something is missing. It never blocks: credentials\n// can be configured before Docker is sorted out.\nfunc systemCheckGroup(sys platform.SystemInfo) *huh.Group {\n\tvar b strings.Builder\n\n\tosLine := sys.OSLabel() + \" (\" + sys.Arch + \")\"\n\tif sys.IsWSL {\n\t\tosLine += \" · WSL2\"\n\t}\n\tb.WriteString(\"OS \" + osLine + \"\\n\")\n\n\tdocker := \"not installed\"\n\tif sys.DockerInstalled {\n\t\tdocker = \"installed\"\n\t\tif sys.DockerRunning {\n\t\t\tdocker += \" · running\"\n\t\t} else {\n\t\t\tdocker += \" · stopped\"\n\t\t}\n\t\tif sys.ComposeAvailable {\n\t\t\tdocker += \" · compose v2\"\n\t\t}\n\t}\n\tb.WriteString(\"Docker \" + docker + \"\\n\\n\")\n\n\tif sys.Ready() {\n\t\tb.WriteString(\"This system is ready to run Decepticon.\")\n\t} else {\n\t\tb.WriteString(sys.DockerHint() + \"\\n\")\n\t\tb.WriteString(\"You can still configure credentials now and sort out Docker before launching.\")\n\t}\n\n\treturn huh.NewGroup(\n\t\thuh.NewNote().\n\t\t\tTitle(\"System Check\").\n\t\t\tDescription(b.String()),\n\t)\n}\n\nfunc runOnboard(cmd *cobra.Command, args []string) error {\n\tif config.EnvExists() && !resetFlag {\n\t\tui.Info(\".env already configured at \" + config.EnvPath())\n\t\tui.DimText(\"Run 'decepticon onboard --reset' to reconfigure\")\n\t\treturn nil\n\t}\n\n\t// Kick off Ollama discovery in the background so the network\n\t// round-trip overlaps with huh's startup work; the OLLAMA_MODEL\n\t// field type depends on the result (Select vs remediation Note).\n\tprobeCh := make(chan ollamaProbeResult, 1)\n\tgo func() {\n\t\tprobeCh <- probeOllamaForOnboard(defaultOllamaAPIBase)\n\t}()\n\n\tvar (\n\t\tmethods []string\n\t\tanthropicKey string\n\t\topenaiKey string\n\t\tgeminiKey string\n\t\tminimaxKey string\n\t\tdeepseekKey string\n\t\txaiKey string\n\t\tmistralKey string\n\t\topenrouterKey string\n\t\tnvidiaKey string\n\t\tgeminiSessionCookies string\n\t\tcopilotRefreshToken string\n\t\tgrokSessionToken string\n\t\tperplexitySessionToken string\n\t\tollamaAPIBase = defaultOllamaAPIBase\n\t\tollamaModel = defaultOllamaModel\n\t\t// Cloud gateways\n\t\tawsAccessKeyID string\n\t\tawsSecretAccessKey string\n\t\tawsRegion = \"us-east-1\"\n\t\tvertexCredsPath string\n\t\tvertexProject string\n\t\tvertexLocation = \"us-central1\"\n\t\tazureAPIKey string\n\t\tazureAPIBase string\n\t\tazureAPIVersion = defaultAzureAPIVersion\n\t\tgroqKey string\n\t\ttogetherKey string\n\t\tfireworksKey string\n\t\tcohereKey string\n\t\tmoonshotKey string\n\t\tzaiKey string\n\t\tdashscopeKey string\n\t\tgithubToken string\n\t\t// Cloud Ollama\n\t\tollamaCloudAPIBase string\n\t\tollamaCloudAPIKey string\n\t\tollamaCloudModel string\n\t\t// LM Studio (local OpenAI-compatible)\n\t\tlmStudioAPIBase = defaultLMStudioAPIBase\n\t\tlmStudioModel = defaultLMStudioModel\n\t\t// Custom OpenAI-compatible endpoint\n\t\tcustomOpenAIAPIBase string\n\t\tcustomOpenAIAPIKey string\n\t\tcustomOpenAIModel string\n\t\tprofile string\n\t\tlanguage = \"en\"\n\t\tuseLangSmith bool\n\t\tlangSmithKey string\n\t)\n\t// Block on the probe (zero-value result on timeout means\n\t// \"unreachable\" — drops through to the remediation Note).\n\t// time.NewTimer + Stop avoids the time.After timer leak when the\n\t// probe finishes first.\n\tprobeTimer := time.NewTimer(onboardOllamaProbeBudget)\n\tvar ollamaProbe ollamaProbeResult\n\tselect {\n\tcase ollamaProbe = <-probeCh:\n\t\tprobeTimer.Stop()\n\tcase <-probeTimer.C:\n\t}\n\tollamaModelField := buildOllamaModelField(ollamaProbe, &ollamaModel)\n\n\t// Probe the host environment so the wizard can confirm — up front —\n\t// that this machine (whatever OS/arch it is) can actually run the\n\t// Docker stack, and surface remediation before the user spends time\n\t// entering credentials.\n\tsysInfo := platform.Detect()\n\n\tform := huh.NewForm(\n\t\t// Step 0: system check.\n\t\tsystemCheckGroup(sysInfo),\n\n\t\t// Intro\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Decepticon Setup\").\n\t\t\t\tDescription(\"Configure auth methods, model profile, and\\nobservability.\\n\\nUse ↑↓ to navigate, space to toggle, Enter to confirm.\"),\n\t\t),\n\n\t\t// Step 1: Auth methods (multi-select)\n\t\thuh.NewGroup(\n\t\t\thuh.NewMultiSelect[string]().\n\t\t\t\tTitle(\"Auth Methods\").\n\t\t\t\tDescription(\"Press SPACE to toggle, ENTER to confirm.\\nUse ↑↓ to scroll, '/' to filter the list.\\nPick every credential you have — each is an\\nindependent fallback in priority order.\").\n\t\t\t\tFilterable(true).\n\t\t\t\tHeight(15).\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"Claude Code OAuth — Anthropic subscription (auth/*)\", methodAnthropicOAuth),\n\t\t\t\t\thuh.NewOption(\"Anthropic API Key — sk-ant-...\", methodAnthropicAPI),\n\t\t\t\t\thuh.NewOption(\"ChatGPT OAuth — ChatGPT Pro/Plus/Team subscription via `codex login` (auth/gpt-*)\", methodOpenAIOAuth),\n\t\t\t\t\thuh.NewOption(\"OpenAI API Key — sk-...\", methodOpenAIAPI),\n\t\t\t\t\thuh.NewOption(\"Google API Key — AIza... (Gemini)\", methodGoogleAPI),\n\t\t\t\t\thuh.NewOption(\"MiniMax API Key — eyJ...\", methodMiniMaxAPI),\n\t\t\t\t\thuh.NewOption(\"DeepSeek API Key — sk-...\", methodDeepSeekAPI),\n\t\t\t\t\thuh.NewOption(\"xAI API Key — xai-... (Grok)\", methodXAIAPI),\n\t\t\t\t\thuh.NewOption(\"Mistral API Key — (no fixed prefix)\", methodMistralAPI),\n\t\t\t\t\thuh.NewOption(\"OpenRouter API Key — sk-or-...\", methodOpenRouterAPI),\n\t\t\t\t\thuh.NewOption(\"Nvidia NIM API Key — nvapi-...\", methodNvidiaAPI),\n\t\t\t\t\thuh.NewOption(\"Gemini Advanced — Google One AI Premium subscription (gemini-sub/*)\", methodGoogleOAuth),\n\t\t\t\t\thuh.NewOption(\"SuperGrok — X Premium+ Grok subscription (grok-sub/*)\", methodGrokOAuth),\n\t\t\t\t\thuh.NewOption(\"GitHub Copilot Pro — Copilot subscription (copilot/*)\", methodCopilotOAuth),\n\t\t\t\t\thuh.NewOption(\"Perplexity Pro — Perplexity subscription (pplx-sub/*)\", methodPerplexityOAuth),\n\t\t\t\t\thuh.NewOption(\"AWS Bedrock — Anthropic models on AWS (bedrock/*)\", methodBedrockAPI),\n\t\t\t\t\thuh.NewOption(\"GCP Vertex AI — Anthropic + Gemini on GCP (vertex_ai/*)\", methodVertexAPI),\n\t\t\t\t\thuh.NewOption(\"Azure OpenAI — Azure-hosted GPT deployments (azure/*)\", methodAzureAPI),\n\t\t\t\t\thuh.NewOption(\"GitHub Models — GPT family via GitHub PAT (github/*)\", methodGitHubModelsAPI),\n\t\t\t\t\thuh.NewOption(\"Groq Cloud — LPU-accelerated Llama (groq/*)\", methodGroqAPI),\n\t\t\t\t\thuh.NewOption(\"Together AI — Llama / Mixtral hub (together_ai/*)\", methodTogetherAPI),\n\t\t\t\t\thuh.NewOption(\"Fireworks AI — Llama / Mixtral hub (fireworks_ai/*)\", methodFireworksAPI),\n\t\t\t\t\thuh.NewOption(\"Cohere Command — Command-A / Command-R (cohere/*)\", methodCohereAPI),\n\t\t\t\t\thuh.NewOption(\"Moonshot Kimi K2 — Kimi K2 (moonshot/*)\", methodMoonshotAPI),\n\t\t\t\t\thuh.NewOption(\"Z.ai GLM-4.5 — GLM-4.5 / GLM-4.5-Air (zai/*)\", methodZaiAPI),\n\t\t\t\t\thuh.NewOption(\"Alibaba DashScope — Qwen Max/Plus/Turbo (dashscope/*)\", methodDashscopeAPI),\n\t\t\t\t\thuh.NewOption(\"LM Studio (local) — local OpenAI-compatible server (lm_studio/*)\", methodLMStudioLocal),\n\t\t\t\t\thuh.NewOption(\"Local LLM (Ollama) — any pulled model, no API key\", methodOllamaLocal),\n\t\t\t\t\thuh.NewOption(\"Ollama Cloud — hosted Ollama (ollama_chat/* via cloud)\", methodOllamaCloud),\n\t\t\t\t\thuh.NewOption(\"Custom OpenAI Endpoint — bring-your-own OpenAI-compatible URL\", methodCustomOpenAIAPI),\n\t\t\t\t).\n\t\t\t\tValue(&methods).\n\t\t\t\tValidate(func(s []string) error {\n\t\t\t\t\tif len(s) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"select at least one credential\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"1 / 5 · Credentials\").\n\t\t\tDescription(\"Select all that apply\"),\n\n\t\t// Step 2a: Anthropic API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Anthropic API Key\").\n\t\t\t\tPlaceholder(\"sk-ant-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&anthropicKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Anthropic API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicAPI) }),\n\n\t\t// Step 2c: OpenAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenAI API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenAI API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIAPI) }),\n\n\t\t// Step 2c: Google API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Google (Gemini) API Key\").\n\t\t\t\tPlaceholder(\"AIza...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Google API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleAPI) }),\n\n\t\t// Step 2d: MiniMax API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MiniMax API Key\").\n\t\t\t\tPlaceholder(\"eyJ...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&minimaxKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · MiniMax API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMiniMaxAPI) }),\n\n\t\t// Step 2d-i: DeepSeek API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DeepSeek API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&deepseekKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DeepSeek API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDeepSeekAPI) }),\n\n\t\t// Step 2d-ii: xAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"xAI API Key\").\n\t\t\t\tPlaceholder(\"xai-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&xaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · xAI API (Grok)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodXAIAPI) }),\n\n\t\t// Step 2d-iii: Mistral API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Mistral API Key\").\n\t\t\t\tPlaceholder(\"paste your Mistral API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&mistralKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Mistral API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMistralAPI) }),\n\n\t\t// Step 2e: OpenRouter API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenRouter API Key\").\n\t\t\t\tPlaceholder(\"sk-or-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openrouterKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenRouter API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenRouterAPI) }),\n\n\t\t// Step 2f: Nvidia NIM API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Nvidia NIM API Key\").\n\t\t\t\tPlaceholder(\"nvapi-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&nvidiaKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Nvidia NIM API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodNvidiaAPI) }),\n\n\t\t// Step 2-oauth-i: Gemini Advanced subscription\n\t\t// Multi-cookie value (NID + Secure-1PSID + Secure-1PSIDTS, etc.)\n\t\t// joined with semicolons. Optional — power users can drop a\n\t\t// tokens.json under ~/.config/gemini/ instead and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Gemini Advanced Subscription\").\n\t\t\t\tDescription(\"Open gemini.google.com → DevTools → Application →\\nCookies → gemini.google.com. Copy the values of\\nNID, __Secure-1PSID, __Secure-1PSIDTS as a single\\nsemicolon-joined string. Or leave blank to use\\nGEMINI_ACCESS_TOKEN / ~/.config/gemini/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GEMINI_SESSION_COOKIES\").\n\t\t\t\tPlaceholder(\"NID=...; __Secure-1PSID=... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiSessionCookies),\n\t\t).Title(\"2 / 5 · Gemini Advanced\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleOAuth) }),\n\n\t\t// Step 2-oauth-ii: SuperGrok subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"SuperGrok Subscription\").\n\t\t\t\tDescription(\"Open grok.com → DevTools → Application →\\nCookies → grok.com → copy the value of\\n`auth_token`. Or leave blank to use\\nGROK_ACCESS_TOKEN / ~/.config/grok/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROK_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste auth_token cookie value (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&grokSessionToken),\n\t\t).Title(\"2 / 5 · SuperGrok\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGrokOAuth) }),\n\n\t\t// Step 2-oauth-iii: GitHub Copilot Pro subscription\n\t\t// Refresh token rotation, not a single session cookie. Devs can\n\t\t// extract one from VSCode's Copilot extension config or follow\n\t\t// gh-copilot-cli onboarding. Optional via tokens.json fallback.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GitHub Copilot Pro Subscription\").\n\t\t\t\tDescription(\"Provide a Copilot refresh token (long-lived, used\\nto rotate access tokens). Or leave blank to use\\nCOPILOT_ACCESS_TOKEN / ~/.config/copilot/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COPILOT_REFRESH_TOKEN\").\n\t\t\t\tPlaceholder(\"ghu_... or ghr_... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&copilotRefreshToken),\n\t\t).Title(\"2 / 5 · Copilot Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCopilotOAuth) }),\n\n\t\t// Step 2-oauth-iv: Perplexity Pro subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Perplexity Pro Subscription\").\n\t\t\t\tDescription(\"Open perplexity.ai → DevTools → Application →\\nCookies → perplexity.ai → copy the value of\\n`next-auth.session-token`. Or leave blank to use\\nPERPLEXITY_ACCESS_TOKEN / ~/.config/perplexity/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"PERPLEXITY_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste next-auth.session-token (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&perplexitySessionToken),\n\t\t).Title(\"2 / 5 · Perplexity Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodPerplexityOAuth) }),\n\n\t\t// Step 2g: Local Ollama. The OLLAMA_MODEL field is built from\n\t\t// the host probe (buildOllamaModelField): a strict Select when\n\t\t// tool-capable models are pulled, a remediation Note otherwise.\n\t\t// The post-form gate refuses to write .env in the Note cases.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Local Ollama\").\n\t\t\t\tDescription(\"Ollama must already be running on your host with\\nat least one tool-capable model pulled. Launch it\\nbound to all interfaces so the Decepticon container\\ncan reach it:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\\nThe default 127.0.0.1 binding only accepts host-side\\nconnections — containers won't see it.\\n\\nThe default URL `host.docker.internal:11434` works\\non macOS, Linux, and WSL2 (with or without Docker\\nDesktop). Only change it for remote / custom setups.\\nNote: the model list is probed against the default URL\\nat wizard start; if you customize OLLAMA_API_BASE,\\nfinish the wizard then re-run 'decepticon onboard\\n--reset' so the probe targets the new endpoint.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_API_BASE\").\n\t\t\t\tPlaceholder(defaultOllamaAPIBase).\n\t\t\t\tValue(&ollamaAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\tollamaModelField,\n\t\t).Title(\"2 / 5 · Local LLM (Ollama)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaLocal) }),\n\n\t\t// Step 2-cloud-i: AWS Bedrock — three-field group (key + secret + region)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"AWS Bedrock\").\n\t\t\t\tDescription(\"Bedrock uses AWS SigV4 — provide IAM access key,\\nsecret, and the region where Anthropic models are\\nenabled (us-east-1 or us-west-2 typically).\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_ACCESS_KEY_ID\").\n\t\t\t\tPlaceholder(\"AKIA...\").\n\t\t\t\tValue(&awsAccessKeyID).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_SECRET_ACCESS_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&awsSecretAccessKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_REGION\").\n\t\t\t\tPlaceholder(\"us-east-1\").\n\t\t\t\tValue(&awsRegion).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · AWS Bedrock\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodBedrockAPI) }),\n\n\t\t// Step 2-cloud-ii: GCP Vertex AI — service-account JSON path + project + region\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GCP Vertex AI\").\n\t\t\t\tDescription(\"Vertex AI uses a Google Cloud service-account JSON.\\nDownload one from IAM & Admin → Service Accounts and\\npaste the absolute file path. Project + region must\\nmatch where you enabled Anthropic + Gemini models.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GOOGLE_APPLICATION_CREDENTIALS\").\n\t\t\t\tPlaceholder(\"/abs/path/to/service-account.json\").\n\t\t\t\tValue(&vertexCredsPath).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"VERTEXAI_PROJECT\").\n\t\t\t\tPlaceholder(\"my-gcp-project-id\").\n\t\t\t\tValue(&vertexProject).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"VERTEXAI_LOCATION\").\n\t\t\t\tPlaceholder(\"us-central1\").\n\t\t\t\tValue(&vertexLocation).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · GCP Vertex AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodVertexAPI) }),\n\n\t\t// Step 2-cloud-iii: Azure OpenAI — key + endpoint + version\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Azure OpenAI Service\").\n\t\t\t\tDescription(\"Endpoint is your azure-openai resource URL,\\ne.g. https://my-resource.openai.azure.com.\\nThe deployment names map 1:1 to gpt-5.5/gpt-5.4/...\\nso the model matrix in DF resolves out of the box.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&azureAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_BASE\").\n\t\t\t\tPlaceholder(\"https://<resource>.openai.azure.com\").\n\t\t\t\tValue(&azureAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_VERSION\").\n\t\t\t\tPlaceholder(defaultAzureAPIVersion).\n\t\t\t\tValue(&azureAPIVersion).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Azure OpenAI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAzureAPI) }),\n\n\t\t// Step 2-cloud-iv: GitHub Models — PAT\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GITHUB_TOKEN\").\n\t\t\t\tDescription(\"Fine-grained PAT with the 'models' permission scope.\\nGenerate at github.com/settings/personal-access-tokens.\").\n\t\t\t\tPlaceholder(\"github_pat_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&githubToken).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · GitHub Models\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGitHubModelsAPI) }),\n\n\t\t// Step 2-cloud-v: Groq\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROQ_API_KEY\").\n\t\t\t\tPlaceholder(\"gsk_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&groqKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Groq Cloud\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGroqAPI) }),\n\n\t\t// Step 2-cloud-vi: Together AI\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"TOGETHER_API_KEY\").\n\t\t\t\tPlaceholder(\"paste your Together AI API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&togetherKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Together AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodTogetherAPI) }),\n\n\t\t// Step 2-cloud-vii: Fireworks AI\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"FIREWORKS_API_KEY\").\n\t\t\t\tPlaceholder(\"fw_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&fireworksKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Fireworks AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodFireworksAPI) }),\n\n\t\t// Step 2-cloud-viii: Cohere\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COHERE_API_KEY\").\n\t\t\t\tPlaceholder(\"paste your Cohere API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&cohereKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Cohere\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCohereAPI) }),\n\n\t\t// Step 2-cloud-ix: Moonshot Kimi K2\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MOONSHOT_API_KEY\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&moonshotKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Moonshot Kimi K2\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMoonshotAPI) }),\n\n\t\t// Step 2-cloud-x: Z.ai GLM-4.5\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"ZAI_API_KEY\").\n\t\t\t\tDescription(\"Z.ai is OpenAI-compatible — base URL is hard-wired to\\nhttps://api.z.ai/api/paas/v4. Get a key at z.ai/manage.\").\n\t\t\t\tPlaceholder(\"paste your Z.ai API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&zaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Z.ai GLM-4.5\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodZaiAPI) }),\n\n\t\t// Step 2-cloud-xi: Alibaba DashScope (Qwen)\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DASHSCOPE_API_KEY\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&dashscopeKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DashScope (Qwen)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDashscopeAPI) }),\n\n\t\t// Step 2-local-ii: LM Studio (local OpenAI-compatible)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"LM Studio\").\n\t\t\t\tDescription(\"LM Studio runs a local OpenAI-compatible server.\\nLaunch the LM Studio app and start the server\\n(Developer tab → Start). The default port is 1234.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LMSTUDIO_API_BASE\").\n\t\t\t\tPlaceholder(defaultLMStudioAPIBase).\n\t\t\t\tValue(&lmStudioAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LMSTUDIO_MODEL\").\n\t\t\t\tDescription(\"Model identifier as shown in LM Studio (e.g.\\nqwen2.5-coder-7b-instruct).\").\n\t\t\t\tPlaceholder(defaultLMStudioModel).\n\t\t\t\tValue(&lmStudioModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · LM Studio\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodLMStudioLocal) }),\n\n\t\t// Step 2-local-iii: Ollama Cloud (hosted)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Ollama Cloud\").\n\t\t\t\tDescription(\"Hosted Ollama — same /api/chat tool-calling endpoint\\nas local Ollama, just behind an API key.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_API_BASE\").\n\t\t\t\tPlaceholder(\"https://api.ollama.com/v1\").\n\t\t\t\tValue(&ollamaCloudAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&ollamaCloudAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_MODEL\").\n\t\t\t\tPlaceholder(\"llama3.3:70b\").\n\t\t\t\tValue(&ollamaCloudModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Ollama Cloud\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaCloud) }),\n\n\t\t// Step 2-custom: Custom OpenAI-compatible endpoint\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Custom OpenAI-compatible Endpoint\").\n\t\t\t\tDescription(\"Point at any OpenAI-compatible server (gateways,\\nself-hosted vLLM, internal LiteLLM, etc.). The model\\nname is sent verbatim to the upstream — match what\\nyour gateway exposes.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_API_BASE\").\n\t\t\t\tPlaceholder(\"https://gateway.example.com/v1\").\n\t\t\t\tValue(&customOpenAIAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&customOpenAIAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_MODEL\").\n\t\t\t\tPlaceholder(\"gpt-4o-mini\").\n\t\t\t\tValue(&customOpenAIModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Custom OpenAI Endpoint\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCustomOpenAIAPI) }),\n\n\t\t// Step 3: Model profile\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Model Profile\").\n\t\t\t\tDescription(\"eco per-agent tier (recommended)\\nmax every agent on HIGH (expensive)\\ntest every agent on LOW (development)\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"eco — per-agent tier (recommended)\", \"eco\"),\n\t\t\t\t\thuh.NewOption(\"max — every agent on HIGH (expensive)\", \"max\"),\n\t\t\t\t\thuh.NewOption(\"test — every agent on LOW (development)\", \"test\"),\n\t\t\t\t).\n\t\t\t\tValue(&profile),\n\t\t).Title(\"3 / 5 · Profile\"),\n\n\t\t// Step 4: Language\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Agent Language\").\n\t\t\t\tDescription(\"Language for all agent prose output (menus, questions,\\nsummaries, errors). Technical output stays in English.\\nCountry codes (dk, se, jp, cn) are auto-resolved.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"en — English (default)\", \"en\"),\n\t\t\t\t\thuh.NewOption(\"no — Norwegian\", \"no\"),\n\t\t\t\t\thuh.NewOption(\"da — Danish\", \"da\"),\n\t\t\t\t\thuh.NewOption(\"sv — Swedish\", \"sv\"),\n\t\t\t\t\thuh.NewOption(\"fi — Finnish\", \"fi\"),\n\t\t\t\t\thuh.NewOption(\"is — Icelandic\", \"is\"),\n\t\t\t\t\thuh.NewOption(\"ko — Korean\", \"ko\"),\n\t\t\t\t\thuh.NewOption(\"ja — Japanese\", \"ja\"),\n\t\t\t\t\thuh.NewOption(\"zh — Chinese\", \"zh\"),\n\t\t\t\t\thuh.NewOption(\"zh-tw — Traditional Chinese\", \"zh-tw\"),\n\t\t\t\t\thuh.NewOption(\"es — Spanish\", \"es\"),\n\t\t\t\t\thuh.NewOption(\"pt — Portuguese\", \"pt\"),\n\t\t\t\t\thuh.NewOption(\"pt-br — Brazilian Portuguese\", \"pt-br\"),\n\t\t\t\t\thuh.NewOption(\"de — German\", \"de\"),\n\t\t\t\t\thuh.NewOption(\"fr — French\", \"fr\"),\n\t\t\t\t\thuh.NewOption(\"nl — Dutch\", \"nl\"),\n\t\t\t\t\thuh.NewOption(\"it — Italian\", \"it\"),\n\t\t\t\t\thuh.NewOption(\"pl — Polish\", \"pl\"),\n\t\t\t\t\thuh.NewOption(\"cs — Czech\", \"cs\"),\n\t\t\t\t\thuh.NewOption(\"uk — Ukrainian\", \"uk\"),\n\t\t\t\t\thuh.NewOption(\"ro — Romanian\", \"ro\"),\n\t\t\t\t\thuh.NewOption(\"hr — Croatian\", \"hr\"),\n\t\t\t\t\thuh.NewOption(\"bg — Bulgarian\", \"bg\"),\n\t\t\t\t\thuh.NewOption(\"ru — Russian\", \"ru\"),\n\t\t\t\t\thuh.NewOption(\"el — Greek\", \"el\"),\n\t\t\t\t\thuh.NewOption(\"hu — Hungarian\", \"hu\"),\n\t\t\t\t\thuh.NewOption(\"tr — Turkish\", \"tr\"),\n\t\t\t\t\thuh.NewOption(\"ar — Arabic\", \"ar\"),\n\t\t\t\t\thuh.NewOption(\"fa — Persian\", \"fa\"),\n\t\t\t\t\thuh.NewOption(\"he — Hebrew\", \"he\"),\n\t\t\t\t\thuh.NewOption(\"hi — Hindi\", \"hi\"),\n\t\t\t\t\thuh.NewOption(\"th — Thai\", \"th\"),\n\t\t\t\t\thuh.NewOption(\"vi — Vietnamese\", \"vi\"),\n\t\t\t\t\thuh.NewOption(\"id — Indonesian\", \"id\"),\n\t\t\t\t\thuh.NewOption(\"ms — Malay\", \"ms\"),\n\t\t\t\t\thuh.NewOption(\"tl — Filipino\", \"tl\"),\n\t\t\t\t\thuh.NewOption(\"sw — Swahili\", \"sw\"),\n\t\t\t\t\thuh.NewOption(\"af — Afrikaans\", \"af\"),\n\t\t\t\t\thuh.NewOption(\"wenyan — 文言文 + English technical terms\", \"wenyan\"),\n\t\t\t\t).\n\t\t\t\tValue(&language),\n\t\t).Title(\"4 / 5 · Language\"),\n\n\t\t// Step 5a: LangSmith toggle\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Enable LangSmith?\").\n\t\t\t\tDescription(\"LLM observability and trace collection\").\n\t\t\t\tAffirmative(\"Yes\").\n\t\t\t\tNegative(\"No\").\n\t\t\t\tValue(&useLangSmith),\n\t\t).Title(\"5 / 5 · Observability\"),\n\n\t\t// Step 5b: LangSmith key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LangSmith API Key\").\n\t\t\t\tPlaceholder(\"lsv2_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&langSmithKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"5 / 5 · LangSmith\").\n\t\t\tWithHideFunc(func() bool { return !useLangSmith }),\n\t).WithTheme(huh.ThemeFunc(ui.DecepticonTheme))\n\n\tif err := form.Run(); err != nil {\n\t\treturn fmt.Errorf(\"setup cancelled: %w\", err)\n\t}\n\n\t// Strict-mode gate: refuse to write .env when the user picked\n\t// Ollama but the host probe found no tool-capable model. The\n\t// in-form Note shows the same remediation; this is the boundary\n\t// guarantee that a broken setup never ships.\n\tif contains(methods, methodOllamaLocal) && len(ollamaProbe.ToolCapableModels) == 0 {\n\t\treturn ollamaUnusableError(ollamaProbe, ollamaAPIBase)\n\t}\n\n\t// huh.MultiSelect returns selected values in option order, not the\n\t// order the user toggled. Re-derive the priority by walking\n\t// methodOrder and keeping only what the user picked.\n\tpriority := make([]string, 0, len(methods))\n\tfor _, m := range methodOrder {\n\t\tif contains(methods, m) {\n\t\t\tpriority = append(priority, m)\n\t\t}\n\t}\n\n\tvalues := map[string]string{\n\t\t\"DECEPTICON_MODEL_PROFILE\": profile,\n\t\t\"DECEPTICON_LANGUAGE\": language,\n\t\t\"DECEPTICON_AUTH_PRIORITY\": strings.Join(priority, \",\"),\n\t\t\"DECEPTICON_AUTH_CLAUDE_CODE\": boolStr(contains(methods, methodAnthropicOAuth)),\n\t\t\"DECEPTICON_AUTH_CHATGPT\": boolStr(contains(methods, methodOpenAIOAuth)),\n\t\t\"DECEPTICON_AUTH_GEMINI\": boolStr(contains(methods, methodGoogleOAuth)),\n\t\t\"DECEPTICON_AUTH_GROK\": boolStr(contains(methods, methodGrokOAuth)),\n\t\t\"DECEPTICON_AUTH_COPILOT\": boolStr(contains(methods, methodCopilotOAuth)),\n\t\t\"DECEPTICON_AUTH_PERPLEXITY\": boolStr(contains(methods, methodPerplexityOAuth)),\n\t}\n\n\tif anthropicKey != \"\" {\n\t\tvalues[\"ANTHROPIC_API_KEY\"] = anthropicKey\n\t}\n\tif openaiKey != \"\" {\n\t\tvalues[\"OPENAI_API_KEY\"] = openaiKey\n\t}\n\tif geminiKey != \"\" {\n\t\tvalues[\"GEMINI_API_KEY\"] = geminiKey\n\t}\n\tif minimaxKey != \"\" {\n\t\tvalues[\"MINIMAX_API_KEY\"] = minimaxKey\n\t}\n\tif deepseekKey != \"\" {\n\t\tvalues[\"DEEPSEEK_API_KEY\"] = deepseekKey\n\t}\n\tif xaiKey != \"\" {\n\t\tvalues[\"XAI_API_KEY\"] = xaiKey\n\t}\n\tif mistralKey != \"\" {\n\t\tvalues[\"MISTRAL_API_KEY\"] = mistralKey\n\t}\n\tif openrouterKey != \"\" {\n\t\tvalues[\"OPENROUTER_API_KEY\"] = openrouterKey\n\t}\n\tif nvidiaKey != \"\" {\n\t\tvalues[\"NVIDIA_API_KEY\"] = nvidiaKey\n\t}\n\tif geminiSessionCookies != \"\" {\n\t\tvalues[\"GEMINI_SESSION_COOKIES\"] = geminiSessionCookies\n\t}\n\tif grokSessionToken != \"\" {\n\t\tvalues[\"GROK_SESSION_TOKEN\"] = grokSessionToken\n\t}\n\tif copilotRefreshToken != \"\" {\n\t\tvalues[\"COPILOT_REFRESH_TOKEN\"] = copilotRefreshToken\n\t}\n\tif perplexitySessionToken != \"\" {\n\t\tvalues[\"PERPLEXITY_SESSION_TOKEN\"] = perplexitySessionToken\n\t}\n\tif contains(methods, methodOllamaLocal) {\n\t\tvalues[\"OLLAMA_API_BASE\"] = strings.TrimSpace(ollamaAPIBase)\n\t\tvalues[\"OLLAMA_MODEL\"] = strings.TrimSpace(ollamaModel)\n\t}\n\tif contains(methods, methodOllamaCloud) {\n\t\tvalues[\"OLLAMA_CLOUD_API_BASE\"] = strings.TrimSpace(ollamaCloudAPIBase)\n\t\tvalues[\"OLLAMA_CLOUD_API_KEY\"] = strings.TrimSpace(ollamaCloudAPIKey)\n\t\tvalues[\"OLLAMA_CLOUD_MODEL\"] = strings.TrimSpace(ollamaCloudModel)\n\t}\n\tif contains(methods, methodBedrockAPI) {\n\t\tvalues[\"AWS_ACCESS_KEY_ID\"] = strings.TrimSpace(awsAccessKeyID)\n\t\tvalues[\"AWS_SECRET_ACCESS_KEY\"] = strings.TrimSpace(awsSecretAccessKey)\n\t\t// LiteLLM's Bedrock provider reads AWS_REGION_NAME, not the\n\t\t// boto3-standard AWS_REGION. Set both for SDK compatibility.\n\t\tvalues[\"AWS_REGION_NAME\"] = strings.TrimSpace(awsRegion)\n\t\tvalues[\"AWS_REGION\"] = strings.TrimSpace(awsRegion)\n\t}\n\tif contains(methods, methodVertexAPI) {\n\t\tvalues[\"GOOGLE_APPLICATION_CREDENTIALS\"] = strings.TrimSpace(vertexCredsPath)\n\t\tvalues[\"VERTEXAI_PROJECT\"] = strings.TrimSpace(vertexProject)\n\t\tvalues[\"VERTEXAI_LOCATION\"] = strings.TrimSpace(vertexLocation)\n\t}\n\tif contains(methods, methodAzureAPI) {\n\t\tvalues[\"AZURE_API_KEY\"] = strings.TrimSpace(azureAPIKey)\n\t\tvalues[\"AZURE_API_BASE\"] = strings.TrimSpace(azureAPIBase)\n\t\tvalues[\"AZURE_API_VERSION\"] = strings.TrimSpace(azureAPIVersion)\n\t}\n\tif githubToken != \"\" {\n\t\t// LiteLLM's github/ provider reads GITHUB_API_KEY; the GitHub\n\t\t// CLI + most docs use GITHUB_TOKEN. Set both so either path\n\t\t// works without operator surprise.\n\t\tvalues[\"GITHUB_TOKEN\"] = strings.TrimSpace(githubToken)\n\t\tvalues[\"GITHUB_API_KEY\"] = strings.TrimSpace(githubToken)\n\t}\n\tif groqKey != \"\" {\n\t\tvalues[\"GROQ_API_KEY\"] = strings.TrimSpace(groqKey)\n\t}\n\tif togetherKey != \"\" {\n\t\tvalues[\"TOGETHER_API_KEY\"] = strings.TrimSpace(togetherKey)\n\t}\n\tif fireworksKey != \"\" {\n\t\tvalues[\"FIREWORKS_API_KEY\"] = strings.TrimSpace(fireworksKey)\n\t}\n\tif cohereKey != \"\" {\n\t\tvalues[\"COHERE_API_KEY\"] = strings.TrimSpace(cohereKey)\n\t}\n\tif moonshotKey != \"\" {\n\t\tvalues[\"MOONSHOT_API_KEY\"] = strings.TrimSpace(moonshotKey)\n\t}\n\tif zaiKey != \"\" {\n\t\tvalues[\"ZAI_API_KEY\"] = strings.TrimSpace(zaiKey)\n\t}\n\tif dashscopeKey != \"\" {\n\t\tvalues[\"DASHSCOPE_API_KEY\"] = strings.TrimSpace(dashscopeKey)\n\t}\n\tif contains(methods, methodLMStudioLocal) {\n\t\tvalues[\"LMSTUDIO_API_BASE\"] = strings.TrimSpace(lmStudioAPIBase)\n\t\tvalues[\"LMSTUDIO_MODEL\"] = strings.TrimSpace(lmStudioModel)\n\t\t// LM Studio accepts any string as the API key; set a sentinel\n\t\t// so LiteLLM's openai-compatible shim doesn't fail an empty\n\t\t// Authorization header.\n\t\tvalues[\"LMSTUDIO_API_KEY\"] = \"lm-studio\"\n\t}\n\tif contains(methods, methodCustomOpenAIAPI) {\n\t\tvalues[\"CUSTOM_OPENAI_API_BASE\"] = strings.TrimSpace(customOpenAIAPIBase)\n\t\tvalues[\"CUSTOM_OPENAI_API_KEY\"] = strings.TrimSpace(customOpenAIAPIKey)\n\t\tvalues[\"CUSTOM_OPENAI_MODEL\"] = strings.TrimSpace(customOpenAIModel)\n\t}\n\n\tif useLangSmith && langSmithKey != \"\" {\n\t\tvalues[\"LANGSMITH_TRACING\"] = \"true\"\n\t\tvalues[\"LANGSMITH_API_KEY\"] = langSmithKey\n\t\tvalues[\"LANGSMITH_PROJECT\"] = \"decepticon\"\n\t}\n\n\tif err := config.WriteEnvFromEmbed(config.EnvPath(), values); err != nil {\n\t\treturn fmt.Errorf(\"write .env: %w\", err)\n\t}\n\n\t// Summary\n\tfmt.Println()\n\tfmt.Println(ui.Green.Render(\" ✓ Configuration saved\"))\n\tfmt.Println()\n\tfmt.Println(ui.Dim.Render(\" ┌──────────────────────────────────┐\"))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Methods \") + ui.Dim.Render(strings.Join(priority, \", \")))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Profile \") + ui.Dim.Render(profile))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Language \") + ui.Dim.Render(language))\n\tif useLangSmith {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" LangSmith \") + ui.Green.Render(\"enabled\"))\n\t}\n\tfmt.Println(ui.Dim.Render(\" │\"))\n\tfmt.Println(ui.Dim.Render(\" │ \") + ui.Dim.Render(config.EnvPath()))\n\tfmt.Println(ui.Dim.Render(\" └──────────────────────────────────┘\"))\n\tfmt.Println()\n\n\t// Install opscontrol as a managed service (ADR-0006). Idempotent\n\t// — re-running onboard re-templates the unit so a launcher\n\t// upgrade picks up the new ExecStart path. On hosts without\n\t// systemd-user / launchd this is a no-op announcement, and the\n\t// launcher's spawn fallback takes over at `decepticon start`.\n\tfmt.Println()\n\tif err := opscontrol.EnsureInstalled(); err != nil {\n\t\t// Non-fatal: onboarding can complete without managed-service\n\t\t// mode, the user will just stay on launcher-spawn fallback.\n\t\tui.Warning(\"opscontrol install skipped: \" + err.Error())\n\t}\n\tfmt.Println()\n\n\t// One-time GitHub star ask — the natural post-onboarding moment.\n\t// Suppressed on subsequent runs by the ack file at\n\t// $DECEPTICON_HOME/.starred, so a re-run of `decepticon onboard\n\t// --reset` does not re-prompt.\n\tstarprompt.PromptIfNotStarred()\n\n\tui.DimText(\" Run 'decepticon' to start the platform\")\n\treturn nil\n}\n\nfunc contains(haystack []string, needle string) bool {\n\treturn slices.Contains(haystack, needle)\n}\n\nfunc nonEmpty(s string) error {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn fmt.Errorf(\"value is required\")\n\t}\n\treturn nil\n}\n\nfunc boolStr(b bool) string {\n\tif b {\n\t\treturn \"true\"\n\t}\n\treturn \"false\"\n}\n\n// buildOllamaModelField returns the OLLAMA_MODEL form field that\n// matches the host probe outcome: strict Select when tool-capable\n// models are pulled, otherwise a remediation Note (different message\n// for the reachable-but-no-tools case vs unreachable Ollama).\nfunc buildOllamaModelField(probe ollamaProbeResult, selected *string) huh.Field {\n\tif len(probe.ToolCapableModels) > 0 {\n\t\toptions := make([]huh.Option[string], 0, len(probe.ToolCapableModels))\n\t\tfor _, m := range probe.ToolCapableModels {\n\t\t\toptions = append(options, huh.NewOption(m, m))\n\t\t}\n\t\tif !slices.Contains(probe.ToolCapableModels, *selected) {\n\t\t\t*selected = probe.ToolCapableModels[0]\n\t\t}\n\t\treturn huh.NewSelect[string]().\n\t\t\tTitle(\"OLLAMA_MODEL\").\n\t\t\tDescription(\"Tool-capable models found on your host. Decepticon\\nagents always emit tool calls — these are the only\\nmodels the wizard will accept.\").\n\t\t\tOptions(options...).\n\t\t\tValue(selected)\n\t}\n\n\tif probe.Reachable {\n\t\treturn huh.NewNote().\n\t\t\tTitle(\"OLLAMA_MODEL — no tool-capable models found\").\n\t\t\tDescription(\"Ollama is reachable but none of your pulled models\\nadvertise the 'tools' capability. Decepticon agents\\nalways emit tool calls, so a model without tool\\nsupport cannot power them.\\n\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n\t}\n\n\treturn huh.NewNote().\n\t\tTitle(\"OLLAMA_MODEL — Ollama not reachable\").\n\t\tDescription(\"Could not reach Ollama at \" + defaultOllamaAPIBase + \".\\n\\nMost likely Ollama isn't running or is bound to\\n127.0.0.1 only (which the Decepticon container can't\\nsee). Launch it on all interfaces, pull a tool-capable\\nmodel, then re-run the wizard:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n}\n\n// ollamaUnusableError surfaces the post-form remediation when the user\n// picked Ollama but the probe found nothing usable. Two flavors so the\n// hint matches whichever in-form Note was shown.\nfunc ollamaUnusableError(probe ollamaProbeResult, baseURL string) error {\n\tif probe.Reachable {\n\t\treturn fmt.Errorf(\n\t\t\t\"Ollama selected but no tool-capable models found on the host.\\n\" +\n\t\t\t\t\"Decepticon agents always emit tool calls — pull a tool-capable\\n\" +\n\t\t\t\t\"model and verify it advertises tools, then re-run:\\n\\n\" +\n\t\t\t\t\" ollama pull qwen3-coder:30b\\n\" +\n\t\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\" +\n\t\t\t\t\" decepticon onboard --reset\")\n\t}\n\treturn fmt.Errorf(\n\t\t\"Ollama selected but the host probe could not reach %s.\\n\"+\n\t\t\t\"Make sure Ollama is running and bound to all interfaces (the\\n\"+\n\t\t\t\"default 127.0.0.1 binding is invisible to containers), then\\n\"+\n\t\t\t\"pull a tool-capable model and re-run:\\n\\n\"+\n\t\t\t\" OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\"+\n\t\t\t\" ollama pull qwen3-coder:30b\\n\"+\n\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\"+\n\t\t\t\" decepticon onboard --reset\",\n\t\tbaseURL)\n}\n"}, "0482fd8125920c7257540022912d137b9b289a74": {"t": 1785755394.0722263, "n": 41336, "h": "b10be3beccc6bc9e", "c": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/huh/v2\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/platform\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/starprompt\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui\"\n\t\"github.com/spf13/cobra\"\n)\n\n// onboardOllamaProbeBudget bounds the wait for the background Ollama\n// discovery probe at form-construction time. Per-request timeout\n// (ollama_models.go) is much shorter; this is the worst-case across\n// all candidate URLs.\nconst onboardOllamaProbeBudget = 3 * time.Second\n\nvar resetFlag bool\n\nvar onboardCmd = &cobra.Command{\n\tUse: \"onboard\",\n\tShort: \"Configure Decepticon (auth methods, model profile, observability)\",\n\tRunE: runOnboard,\n}\n\nfunc init() {\n\tonboardCmd.Flags().BoolVar(&resetFlag, \"reset\", false, \"Reconfigure even if .env already exists\")\n\trootCmd.AddCommand(onboardCmd)\n}\n\n// AuthMethod identifiers — must match decepticon/llm/models.py::AuthMethod.\nconst (\n\tmethodAnthropicOAuth = \"anthropic_oauth\"\n\tmethodAnthropicAPI = \"anthropic_api\"\n\tmethodOpenAIOAuth = \"openai_oauth\"\n\tmethodOpenAIAPI = \"openai_api\"\n\tmethodGoogleOAuth = \"google_oauth\"\n\tmethodGoogleAPI = \"google_api\"\n\tmethodMiniMaxAPI = \"minimax_api\"\n\tmethodDeepSeekAPI = \"deepseek_api\"\n\tmethodXAIAPI = \"xai_api\"\n\tmethodGrokOAuth = \"grok_oauth\"\n\tmethodMistralAPI = \"mistral_api\"\n\tmethodOpenRouterAPI = \"openrouter_api\"\n\tmethodNvidiaAPI = \"nvidia_api\"\n\tmethodCopilotOAuth = \"copilot_oauth\"\n\tmethodPerplexityOAuth = \"perplexity_oauth\"\n\tmethodOllamaLocal = \"ollama_local\"\n\tmethodOllamaCloud = \"ollama_cloud\"\n\t// Cloud gateways added in the OpenClaude provider migration.\n\tmethodBedrockAPI = \"bedrock_api\"\n\tmethodVertexAPI = \"vertex_api\"\n\tmethodAzureAPI = \"azure_api\"\n\tmethodGroqAPI = \"groq_api\"\n\tmethodTogetherAPI = \"together_api\"\n\tmethodFireworksAPI = \"fireworks_api\"\n\tmethodCohereAPI = \"cohere_api\"\n\tmethodMoonshotAPI = \"moonshot_api\"\n\tmethodZaiAPI = \"zai_api\"\n\tmethodDashscopeAPI = \"dashscope_api\"\n\tmethodGitHubModelsAPI = \"github_models_api\"\n\tmethodLMStudioLocal = \"lmstudio_local\"\n\tmethodCustomOpenAIAPI = \"custom_openai_api\"\n)\n\n// Defaults shown in form placeholders for the new providers.\nconst (\n\tdefaultLMStudioAPIBase = \"http://host.docker.internal:1234/v1\"\n\tdefaultLMStudioModel = \"qwen2.5-coder-7b-instruct\"\n\tdefaultAzureAPIVersion = \"2024-08-01-preview\"\n)\n\n// Default Ollama wiring shown to OSS users. `host.docker.internal`\n// is the universal answer regardless of where Ollama runs (macOS host,\n// WSL2 distro, native Linux): from inside Decepticon's containers it\n// resolves to the host network namespace via the\n// `extra_hosts: [host.docker.internal:host-gateway]` entry on the\n// litellm service in docker-compose.yml. `localhost` is never the\n// right answer here — that's the container itself.\n//\n// The host's Ollama must additionally be bound to 0.0.0.0 (the default\n// 127.0.0.1 binding only accepts host-side connections); the wizard\n// surfaces this requirement to the user.\n//\n// The default model is the smallest/fastest one most laptops can\n// actually run; users with a GPU will pick something like qwen3-coder:30b.\nconst (\n\tdefaultOllamaAPIBase = \"http://host.docker.internal:11434\"\n\tdefaultOllamaModel = \"llama3.2\"\n)\n\n// methodOrder is the priority order surfaced in the wizard. The\n// resulting DECEPTICON_AUTH_PRIORITY preserves this order, filtered\n// to the methods the user actually selected. OAuth precedes the\n// matching API on purpose: a subscription primary should fall back\n// to the paid API only when the subscription quota is exhausted.\n// Ollama sits last: cloud providers are usually preferred when both\n// are configured, but a user wanting to lead with local-only inference\n// can reorder the priority manually in .env.\nvar methodOrder = []string{\n\tmethodAnthropicOAuth,\n\tmethodAnthropicAPI,\n\tmethodOpenAIOAuth,\n\tmethodOpenAIAPI,\n\tmethodGoogleOAuth,\n\tmethodGoogleAPI,\n\tmethodMiniMaxAPI,\n\tmethodDeepSeekAPI,\n\tmethodXAIAPI,\n\tmethodGrokOAuth,\n\tmethodMistralAPI,\n\tmethodOpenRouterAPI,\n\tmethodNvidiaAPI,\n\tmethodCopilotOAuth,\n\tmethodPerplexityOAuth,\n\t// Cloud gateways — anthropic-via-cloud paths first, then\n\t// other multi-vendor hubs.\n\tmethodBedrockAPI,\n\tmethodVertexAPI,\n\tmethodAzureAPI,\n\tmethodGitHubModelsAPI,\n\tmethodGroqAPI,\n\tmethodTogetherAPI,\n\tmethodFireworksAPI,\n\tmethodCohereAPI,\n\tmethodMoonshotAPI,\n\tmethodZaiAPI,\n\tmethodDashscopeAPI,\n\t// Local last so cloud-preferred default still picks remote\n\t// providers as primary; users can re-order in .env.\n\tmethodLMStudioLocal,\n\tmethodOllamaLocal,\n\tmethodOllamaCloud,\n\tmethodCustomOpenAIAPI,\n}\n\n// systemCheckGroup renders the host-environment snapshot as the first\n// step of onboarding. It detects OS/architecture (so the user sees the\n// machine is recognized — be it Windows, macOS, a Linux pentest distro,\n// or a Raspberry Pi) and the Docker readiness state, with OS-specific\n// remediation when something is missing. It never blocks: credentials\n// can be configured before Docker is sorted out.\nfunc systemCheckGroup(sys platform.SystemInfo) *huh.Group {\n\tvar b strings.Builder\n\n\tosLine := sys.OSLabel() + \" (\" + sys.Arch + \")\"\n\tif sys.IsWSL {\n\t\tosLine += \" · WSL2\"\n\t}\n\tb.WriteString(\"OS \" + osLine + \"\\n\")\n\n\tdocker := \"not installed\"\n\tif sys.DockerInstalled {\n\t\tdocker = \"installed\"\n\t\tif sys.DockerRunning {\n\t\t\tdocker += \" · running\"\n\t\t} else {\n\t\t\tdocker += \" · stopped\"\n\t\t}\n\t\tif sys.ComposeAvailable {\n\t\t\tdocker += \" · compose v2\"\n\t\t}\n\t}\n\tb.WriteString(\"Docker \" + docker + \"\\n\\n\")\n\n\tif sys.Ready() {\n\t\tb.WriteString(\"This system is ready to run Decepticon.\")\n\t} else {\n\t\tb.WriteString(sys.DockerHint() + \"\\n\")\n\t\tb.WriteString(\"You can still configure credentials now and sort out Docker before launching.\")\n\t}\n\n\treturn huh.NewGroup(\n\t\thuh.NewNote().\n\t\t\tTitle(\"System Check\").\n\t\t\tDescription(b.String()),\n\t)\n}\n\nfunc runOnboard(cmd *cobra.Command, args []string) error {\n\tif config.EnvExists() && !resetFlag {\n\t\tui.Info(\".env already configured at \" + config.EnvPath())\n\t\tui.DimText(\"Run 'decepticon onboard --reset' to reconfigure\")\n\t\treturn nil\n\t}\n\n\t// Kick off Ollama discovery in the background so the network\n\t// round-trip overlaps with huh's startup work; the OLLAMA_MODEL\n\t// field type depends on the result (Select vs remediation Note).\n\tprobeCh := make(chan ollamaProbeResult, 1)\n\tgo func() {\n\t\tprobeCh <- probeOllamaForOnboard(defaultOllamaAPIBase)\n\t}()\n\n\tvar (\n\t\tmethods []string\n\t\tanthropicKey string\n\t\topenaiKey string\n\t\tgeminiKey string\n\t\tminimaxKey string\n\t\tdeepseekKey string\n\t\txaiKey string\n\t\tmistralKey string\n\t\topenrouterKey string\n\t\tnvidiaKey string\n\t\tgeminiSessionCookies string\n\t\tcopilotRefreshToken string\n\t\tgrokSessionToken string\n\t\tperplexitySessionToken string\n\t\tollamaAPIBase = defaultOllamaAPIBase\n\t\tollamaModel = defaultOllamaModel\n\t\t// Cloud gateways\n\t\tawsAccessKeyID string\n\t\tawsSecretAccessKey string\n\t\tawsRegion = \"us-east-1\"\n\t\tvertexCredsPath string\n\t\tvertexProject string\n\t\tvertexLocation = \"us-central1\"\n\t\tazureAPIKey string\n\t\tazureAPIBase string\n\t\tazureAPIVersion = defaultAzureAPIVersion\n\t\tgroqKey string\n\t\ttogetherKey string\n\t\tfireworksKey string\n\t\tcohereKey string\n\t\tmoonshotKey string\n\t\tzaiKey string\n\t\tdashscopeKey string\n\t\tgithubToken string\n\t\t// Cloud Ollama\n\t\tollamaCloudAPIBase string\n\t\tollamaCloudAPIKey string\n\t\tollamaCloudModel string\n\t\t// LM Studio (local OpenAI-compatible)\n\t\tlmStudioAPIBase = defaultLMStudioAPIBase\n\t\tlmStudioModel = defaultLMStudioModel\n\t\t// Custom OpenAI-compatible endpoint\n\t\tcustomOpenAIAPIBase string\n\t\tcustomOpenAIAPIKey string\n\t\tcustomOpenAIModel string\n\t\tprofile string\n\t\tlanguage = \"en\"\n\t\tuseLangSmith bool\n\t\tlangSmithKey string\n\t)\n\t// Block on the probe (zero-value result on timeout means\n\t// \"unreachable\" — drops through to the remediation Note).\n\t// time.NewTimer + Stop avoids the time.After timer leak when the\n\t// probe finishes first.\n\tprobeTimer := time.NewTimer(onboardOllamaProbeBudget)\n\tvar ollamaProbe ollamaProbeResult\n\tselect {\n\tcase ollamaProbe = <-probeCh:\n\t\tprobeTimer.Stop()\n\tcase <-probeTimer.C:\n\t}\n\tollamaModelField := buildOllamaModelField(ollamaProbe, &ollamaModel)\n\n\t// Probe the host environment so the wizard can confirm — up front —\n\t// that this machine (whatever OS/arch it is) can actually run the\n\t// Docker stack, and surface remediation before the user spends time\n\t// entering credentials.\n\tsysInfo := platform.Detect()\n\n\tform := huh.NewForm(\n\t\t// Step 0: system check.\n\t\tsystemCheckGroup(sysInfo),\n\n\t\t// Intro\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Decepticon Setup\").\n\t\t\t\tDescription(\"Configure auth methods, model profile, and\\nobservability.\\n\\nUse ↑↓ to navigate, space to toggle, Enter to confirm.\"),\n\t\t),\n\n\t\t// Step 1: Auth methods (multi-select)\n\t\thuh.NewGroup(\n\t\t\thuh.NewMultiSelect[string]().\n\t\t\t\tTitle(\"Auth Methods\").\n\t\t\t\tDescription(\"Press SPACE to toggle, ENTER to confirm.\\nUse ↑↓ to scroll, '/' to filter the list.\\nPick every credential you have — each is an\\nindependent fallback in priority order.\").\n\t\t\t\tFilterable(true).\n\t\t\t\tHeight(15).\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"Claude Code OAuth — Anthropic subscription (auth/*)\", methodAnthropicOAuth),\n\t\t\t\t\thuh.NewOption(\"Anthropic API Key — sk-ant-...\", methodAnthropicAPI),\n\t\t\t\t\thuh.NewOption(\"ChatGPT OAuth — ChatGPT Pro/Plus/Team subscription via `codex login` (auth/gpt-*)\", methodOpenAIOAuth),\n\t\t\t\t\thuh.NewOption(\"OpenAI API Key — sk-...\", methodOpenAIAPI),\n\t\t\t\t\thuh.NewOption(\"Google API Key — AIza... (Gemini)\", methodGoogleAPI),\n\t\t\t\t\thuh.NewOption(\"MiniMax API Key — eyJ...\", methodMiniMaxAPI),\n\t\t\t\t\thuh.NewOption(\"DeepSeek API Key — sk-...\", methodDeepSeekAPI),\n\t\t\t\t\thuh.NewOption(\"xAI API Key — xai-... (Grok)\", methodXAIAPI),\n\t\t\t\t\thuh.NewOption(\"Mistral API Key — (no fixed prefix)\", methodMistralAPI),\n\t\t\t\t\thuh.NewOption(\"OpenRouter API Key — sk-or-...\", methodOpenRouterAPI),\n\t\t\t\t\thuh.NewOption(\"Nvidia NIM API Key — nvapi-...\", methodNvidiaAPI),\n\t\t\t\t\thuh.NewOption(\"Gemini Advanced — Google One AI Premium subscription (gemini-sub/*)\", methodGoogleOAuth),\n\t\t\t\t\thuh.NewOption(\"SuperGrok — X Premium+ Grok subscription (grok-sub/*)\", methodGrokOAuth),\n\t\t\t\t\thuh.NewOption(\"GitHub Copilot Pro — Copilot subscription (copilot/*)\", methodCopilotOAuth),\n\t\t\t\t\thuh.NewOption(\"Perplexity Pro — Perplexity subscription (pplx-sub/*)\", methodPerplexityOAuth),\n\t\t\t\t\thuh.NewOption(\"AWS Bedrock — Anthropic models on AWS (bedrock/*)\", methodBedrockAPI),\n\t\t\t\t\thuh.NewOption(\"GCP Vertex AI — Anthropic + Gemini on GCP (vertex_ai/*)\", methodVertexAPI),\n\t\t\t\t\thuh.NewOption(\"Azure OpenAI — Azure-hosted GPT deployments (azure/*)\", methodAzureAPI),\n\t\t\t\t\thuh.NewOption(\"GitHub Models — GPT family via GitHub PAT (github/*)\", methodGitHubModelsAPI),\n\t\t\t\t\thuh.NewOption(\"Groq Cloud — LPU-accelerated Llama (groq/*)\", methodGroqAPI),\n\t\t\t\t\thuh.NewOption(\"Together AI — Llama / Mixtral hub (together_ai/*)\", methodTogetherAPI),\n\t\t\t\t\thuh.NewOption(\"Fireworks AI — Llama / Mixtral hub (fireworks_ai/*)\", methodFireworksAPI),\n\t\t\t\t\thuh.NewOption(\"Cohere Command — Command-A / Command-R (cohere/*)\", methodCohereAPI),\n\t\t\t\t\thuh.NewOption(\"Moonshot Kimi K2 — Kimi K2 (moonshot/*)\", methodMoonshotAPI),\n\t\t\t\t\thuh.NewOption(\"Z.ai GLM-4.5 — GLM-4.5 / GLM-4.5-Air (zai/*)\", methodZaiAPI),\n\t\t\t\t\thuh.NewOption(\"Alibaba DashScope — Qwen Max/Plus/Turbo (dashscope/*)\", methodDashscopeAPI),\n\t\t\t\t\thuh.NewOption(\"LM Studio (local) — local OpenAI-compatible server (lm_studio/*)\", methodLMStudioLocal),\n\t\t\t\t\thuh.NewOption(\"Local LLM (Ollama) — any pulled model, no API key\", methodOllamaLocal),\n\t\t\t\t\thuh.NewOption(\"Ollama Cloud — hosted Ollama (ollama_chat/* via cloud)\", methodOllamaCloud),\n\t\t\t\t\thuh.NewOption(\"Custom OpenAI Endpoint — bring-your-own OpenAI-compatible URL\", methodCustomOpenAIAPI),\n\t\t\t\t).\n\t\t\t\tValue(&methods).\n\t\t\t\tValidate(func(s []string) error {\n\t\t\t\t\tif len(s) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"select at least one credential\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"1 / 5 · Credentials\").\n\t\t\tDescription(\"Select all that apply\"),\n\n\t\t// Step 2a: Anthropic API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Anthropic API Key\").\n\t\t\t\tPlaceholder(\"sk-ant-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&anthropicKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Anthropic API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicAPI) }),\n\n\t\t// Step 2c: OpenAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenAI API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenAI API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIAPI) }),\n\n\t\t// Step 2c: Google API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Google (Gemini) API Key\").\n\t\t\t\tPlaceholder(\"AIza...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Google API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleAPI) }),\n\n\t\t// Step 2d: MiniMax API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MiniMax API Key\").\n\t\t\t\tPlaceholder(\"eyJ...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&minimaxKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · MiniMax API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMiniMaxAPI) }),\n\n\t\t// Step 2d-i: DeepSeek API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DeepSeek API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&deepseekKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DeepSeek API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDeepSeekAPI) }),\n\n\t\t// Step 2d-ii: xAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"xAI API Key\").\n\t\t\t\tPlaceholder(\"xai-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&xaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · xAI API (Grok)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodXAIAPI) }),\n\n\t\t// Step 2d-iii: Mistral API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Mistral API Key\").\n\t\t\t\tPlaceholder(\"paste your Mistral API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&mistralKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Mistral API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMistralAPI) }),\n\n\t\t// Step 2e: OpenRouter API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenRouter API Key\").\n\t\t\t\tPlaceholder(\"sk-or-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openrouterKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenRouter API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenRouterAPI) }),\n\n\t\t// Step 2f: Nvidia NIM API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Nvidia NIM API Key\").\n\t\t\t\tPlaceholder(\"nvapi-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&nvidiaKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Nvidia NIM API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodNvidiaAPI) }),\n\n\t\t// Step 2-oauth-i: Gemini Advanced subscription\n\t\t// Multi-cookie value (NID + Secure-1PSID + Secure-1PSIDTS, etc.)\n\t\t// joined with semicolons. Optional — power users can drop a\n\t\t// tokens.json under ~/.config/gemini/ instead and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Gemini Advanced Subscription\").\n\t\t\t\tDescription(\"Open gemini.google.com → DevTools → Application →\\nCookies → gemini.google.com. Copy the values of\\nNID, __Secure-1PSID, __Secure-1PSIDTS as a single\\nsemicolon-joined string. Or leave blank to use\\nGEMINI_ACCESS_TOKEN / ~/.config/gemini/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GEMINI_SESSION_COOKIES\").\n\t\t\t\tPlaceholder(\"NID=...; __Secure-1PSID=... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiSessionCookies),\n\t\t).Title(\"2 / 5 · Gemini Advanced\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleOAuth) }),\n\n\t\t// Step 2-oauth-ii: SuperGrok subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"SuperGrok Subscription\").\n\t\t\t\tDescription(\"Open grok.com → DevTools → Application →\\nCookies → grok.com → copy the value of\\n`auth_token`. Or leave blank to use\\nGROK_ACCESS_TOKEN / ~/.config/grok/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROK_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste auth_token cookie value (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&grokSessionToken),\n\t\t).Title(\"2 / 5 · SuperGrok\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGrokOAuth) }),\n\n\t\t// Step 2-oauth-iii: GitHub Copilot Pro subscription\n\t\t// Refresh token rotation, not a single session cookie. Devs can\n\t\t// extract one from VSCode's Copilot extension config or follow\n\t\t// gh-copilot-cli onboarding. Optional via tokens.json fallback.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GitHub Copilot Pro Subscription\").\n\t\t\t\tDescription(\"Provide a Copilot refresh token (long-lived, used\\nto rotate access tokens). Or leave blank to use\\nCOPILOT_ACCESS_TOKEN / ~/.config/copilot/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COPILOT_REFRESH_TOKEN\").\n\t\t\t\tPlaceholder(\"ghu_... or ghr_... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&copilotRefreshToken),\n\t\t).Title(\"2 / 5 · Copilot Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCopilotOAuth) }),\n\n\t\t// Step 2-oauth-iv: Perplexity Pro subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Perplexity Pro Subscription\").\n\t\t\t\tDescription(\"Open perplexity.ai → DevTools → Application →\\nCookies → perplexity.ai → copy the value of\\n`next-auth.session-token`. Or leave blank to use\\nPERPLEXITY_ACCESS_TOKEN / ~/.config/perplexity/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"PERPLEXITY_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste next-auth.session-token (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&perplexitySessionToken),\n\t\t).Title(\"2 / 5 · Perplexity Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodPerplexityOAuth) }),\n\n\t\t// Step 2g: Local Ollama. The OLLAMA_MODEL field is built from\n\t\t// the host probe (buildOllamaModelField): a strict Select when\n\t\t// tool-capable models are pulled, a remediation Note otherwise.\n\t\t// The post-form gate refuses to write .env in the Note cases.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Local Ollama\").\n\t\t\t\tDescription(\"Ollama must already be running on your host with\\nat least one tool-capable model pulled. Launch it\\nbound to all interfaces so the Decepticon container\\ncan reach it:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\\nThe default 127.0.0.1 binding only accepts host-side\\nconnections — containers won't see it.\\n\\nThe default URL `host.docker.internal:11434` works\\non macOS, Linux, and WSL2 (with or without Docker\\nDesktop). Only change it for remote / custom setups.\\nNote: the model list is probed against the default URL\\nat wizard start; if you customize OLLAMA_API_BASE,\\nfinish the wizard then re-run 'decepticon onboard\\n--reset' so the probe targets the new endpoint.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_API_BASE\").\n\t\t\t\tPlaceholder(defaultOllamaAPIBase).\n\t\t\t\tValue(&ollamaAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\tollamaModelField,\n\t\t).Title(\"2 / 5 · Local LLM (Ollama)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaLocal) }),\n\n\t\t// Step 2-cloud-i: AWS Bedrock — three-field group (key + secret + region)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"AWS Bedrock\").\n\t\t\t\tDescription(\"Bedrock uses AWS SigV4 — provide IAM access key,\\nsecret, and the region where Anthropic models are\\nenabled (us-east-1 or us-west-2 typically).\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_ACCESS_KEY_ID\").\n\t\t\t\tPlaceholder(\"AKIA...\").\n\t\t\t\tValue(&awsAccessKeyID).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_SECRET_ACCESS_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&awsSecretAccessKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_REGION\").\n\t\t\t\tPlaceholder(\"us-east-1\").\n\t\t\t\tValue(&awsRegion).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · AWS Bedrock\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodBedrockAPI) }),\n\n\t\t// Step 2-cloud-ii: GCP Vertex AI — service-account JSON path + project + region\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GCP Vertex AI\").\n\t\t\t\tDescription(\"Vertex AI uses a Google Cloud service-account JSON.\\nDownload one from IAM & Admin → Service Accounts and\\npaste the absolute file path. Project + region must\\nmatch where you enabled Anthropic + Gemini models.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GOOGLE_APPLICATION_CREDENTIALS\").\n\t\t\t\tPlaceholder(\"/abs/path/to/service-account.json\").\n\t\t\t\tValue(&vertexCredsPath).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"VERTEXAI_PROJECT\").\n\t\t\t\tPlaceholder(\"my-gcp-project-id\").\n\t\t\t\tValue(&vertexProject).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"VERTEXAI_LOCATION\").\n\t\t\t\tPlaceholder(\"us-central1\").\n\t\t\t\tValue(&vertexLocation).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · GCP Vertex AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodVertexAPI) }),\n\n\t\t// Step 2-cloud-iii: Azure OpenAI — key + endpoint + version\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Azure OpenAI Service\").\n\t\t\t\tDescription(\"Endpoint is your azure-openai resource URL,\\ne.g. https://my-resource.openai.azure.com.\\nThe deployment names map 1:1 to gpt-5.5/gpt-5.4/...\\nso the model matrix in DF resolves out of the box.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&azureAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_BASE\").\n\t\t\t\tPlaceholder(\"https://<resource>.openai.azure.com\").\n\t\t\t\tValue(&azureAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_VERSION\").\n\t\t\t\tPlaceholder(defaultAzureAPIVersion).\n\t\t\t\tValue(&azureAPIVersion).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Azure OpenAI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAzureAPI) }),\n\n\t\t// Step 2-cloud-iv: GitHub Models — PAT\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GITHUB_TOKEN\").\n\t\t\t\tDescription(\"Fine-grained PAT with the 'models' permission scope.\\nGenerate at github.com/settings/personal-access-tokens.\").\n\t\t\t\tPlaceholder(\"github_pat_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&githubToken).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · GitHub Models\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGitHubModelsAPI) }),\n\n\t\t// Step 2-cloud-v: Groq\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROQ_API_KEY\").\n\t\t\t\tPlaceholder(\"gsk_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&groqKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Groq Cloud\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGroqAPI) }),\n\n\t\t// Step 2-cloud-vi: Together AI\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"TOGETHER_API_KEY\").\n\t\t\t\tPlaceholder(\"paste your Together AI API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&togetherKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Together AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodTogetherAPI) }),\n\n\t\t// Step 2-cloud-vii: Fireworks AI\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"FIREWORKS_API_KEY\").\n\t\t\t\tPlaceholder(\"fw_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&fireworksKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Fireworks AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodFireworksAPI) }),\n\n\t\t// Step 2-cloud-viii: Cohere\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COHERE_API_KEY\").\n\t\t\t\tPlaceholder(\"paste your Cohere API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&cohereKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Cohere\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCohereAPI) }),\n\n\t\t// Step 2-cloud-ix: Moonshot Kimi K2\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MOONSHOT_API_KEY\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&moonshotKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Moonshot Kimi K2\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMoonshotAPI) }),\n\n\t\t// Step 2-cloud-x: Z.ai GLM-4.5\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"ZAI_API_KEY\").\n\t\t\t\tDescription(\"Z.ai is OpenAI-compatible — base URL is hard-wired to\\nhttps://api.z.ai/api/paas/v4. Get a key at z.ai/manage.\").\n\t\t\t\tPlaceholder(\"paste your Z.ai API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&zaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Z.ai GLM-4.5\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodZaiAPI) }),\n\n\t\t// Step 2-cloud-xi: Alibaba DashScope (Qwen)\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DASHSCOPE_API_KEY\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&dashscopeKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DashScope (Qwen)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDashscopeAPI) }),\n\n\t\t// Step 2-local-ii: LM Studio (local OpenAI-compatible)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"LM Studio\").\n\t\t\t\tDescription(\"LM Studio runs a local OpenAI-compatible server.\\nLaunch the LM Studio app and start the server\\n(Developer tab → Start). The default port is 1234.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LMSTUDIO_API_BASE\").\n\t\t\t\tPlaceholder(defaultLMStudioAPIBase).\n\t\t\t\tValue(&lmStudioAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LMSTUDIO_MODEL\").\n\t\t\t\tDescription(\"Model identifier as shown in LM Studio (e.g.\\nqwen2.5-coder-7b-instruct).\").\n\t\t\t\tPlaceholder(defaultLMStudioModel).\n\t\t\t\tValue(&lmStudioModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · LM Studio\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodLMStudioLocal) }),\n\n\t\t// Step 2-local-iii: Ollama Cloud (hosted)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Ollama Cloud\").\n\t\t\t\tDescription(\"Hosted Ollama — same /api/chat tool-calling endpoint\\nas local Ollama, just behind an API key.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_API_BASE\").\n\t\t\t\tPlaceholder(\"https://api.ollama.com/v1\").\n\t\t\t\tValue(&ollamaCloudAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&ollamaCloudAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_MODEL\").\n\t\t\t\tPlaceholder(\"llama3.3:70b\").\n\t\t\t\tValue(&ollamaCloudModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Ollama Cloud\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaCloud) }),\n\n\t\t// Step 2-custom: Custom OpenAI-compatible endpoint\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Custom OpenAI-compatible Endpoint\").\n\t\t\t\tDescription(\"Point at any OpenAI-compatible server (gateways,\\nself-hosted vLLM, internal LiteLLM, etc.). The model\\nname is sent verbatim to the upstream — match what\\nyour gateway exposes.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_API_BASE\").\n\t\t\t\tPlaceholder(\"https://gateway.example.com/v1\").\n\t\t\t\tValue(&customOpenAIAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&customOpenAIAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_MODEL\").\n\t\t\t\tPlaceholder(\"gpt-4o-mini\").\n\t\t\t\tValue(&customOpenAIModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Custom OpenAI Endpoint\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCustomOpenAIAPI) }),\n\n\t\t// Step 3: Model profile\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Model Profile\").\n\t\t\t\tDescription(\"eco per-agent tier (recommended)\\nmax every agent on HIGH (expensive)\\ntest every agent on LOW (development)\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"eco — per-agent tier (recommended)\", \"eco\"),\n\t\t\t\t\thuh.NewOption(\"max — every agent on HIGH (expensive)\", \"max\"),\n\t\t\t\t\thuh.NewOption(\"test — every agent on LOW (development)\", \"test\"),\n\t\t\t\t).\n\t\t\t\tValue(&profile),\n\t\t).Title(\"3 / 5 · Profile\"),\n\n\t\t// Step 4: Language\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Agent Language\").\n\t\t\t\tDescription(\"Language for all agent prose output (menus, questions,\\nsummaries, errors). Technical output stays in English.\\nCountry codes (dk, se, jp, cn) are auto-resolved.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"en — English (default)\", \"en\"),\n\t\t\t\t\thuh.NewOption(\"no — Norwegian\", \"no\"),\n\t\t\t\t\thuh.NewOption(\"da — Danish\", \"da\"),\n\t\t\t\t\thuh.NewOption(\"sv — Swedish\", \"sv\"),\n\t\t\t\t\thuh.NewOption(\"fi — Finnish\", \"fi\"),\n\t\t\t\t\thuh.NewOption(\"is — Icelandic\", \"is\"),\n\t\t\t\t\thuh.NewOption(\"ko — Korean\", \"ko\"),\n\t\t\t\t\thuh.NewOption(\"ja — Japanese\", \"ja\"),\n\t\t\t\t\thuh.NewOption(\"zh — Chinese\", \"zh\"),\n\t\t\t\t\thuh.NewOption(\"zh-tw — Traditional Chinese\", \"zh-tw\"),\n\t\t\t\t\thuh.NewOption(\"es — Spanish\", \"es\"),\n\t\t\t\t\thuh.NewOption(\"pt — Portuguese\", \"pt\"),\n\t\t\t\t\thuh.NewOption(\"pt-br — Brazilian Portuguese\", \"pt-br\"),\n\t\t\t\t\thuh.NewOption(\"de — German\", \"de\"),\n\t\t\t\t\thuh.NewOption(\"fr — French\", \"fr\"),\n\t\t\t\t\thuh.NewOption(\"nl — Dutch\", \"nl\"),\n\t\t\t\t\thuh.NewOption(\"it — Italian\", \"it\"),\n\t\t\t\t\thuh.NewOption(\"pl — Polish\", \"pl\"),\n\t\t\t\t\thuh.NewOption(\"cs — Czech\", \"cs\"),\n\t\t\t\t\thuh.NewOption(\"uk — Ukrainian\", \"uk\"),\n\t\t\t\t\thuh.NewOption(\"ro — Romanian\", \"ro\"),\n\t\t\t\t\thuh.NewOption(\"hr — Croatian\", \"hr\"),\n\t\t\t\t\thuh.NewOption(\"bg — Bulgarian\", \"bg\"),\n\t\t\t\t\thuh.NewOption(\"ru — Russian\", \"ru\"),\n\t\t\t\t\thuh.NewOption(\"el — Greek\", \"el\"),\n\t\t\t\t\thuh.NewOption(\"hu — Hungarian\", \"hu\"),\n\t\t\t\t\thuh.NewOption(\"tr — Turkish\", \"tr\"),\n\t\t\t\t\thuh.NewOption(\"ar — Arabic\", \"ar\"),\n\t\t\t\t\thuh.NewOption(\"fa — Persian\", \"fa\"),\n\t\t\t\t\thuh.NewOption(\"he — Hebrew\", \"he\"),\n\t\t\t\t\thuh.NewOption(\"hi — Hindi\", \"hi\"),\n\t\t\t\t\thuh.NewOption(\"th — Thai\", \"th\"),\n\t\t\t\t\thuh.NewOption(\"vi — Vietnamese\", \"vi\"),\n\t\t\t\t\thuh.NewOption(\"id — Indonesian\", \"id\"),\n\t\t\t\t\thuh.NewOption(\"ms — Malay\", \"ms\"),\n\t\t\t\t\thuh.NewOption(\"tl — Filipino\", \"tl\"),\n\t\t\t\t\thuh.NewOption(\"sw — Swahili\", \"sw\"),\n\t\t\t\t\thuh.NewOption(\"af — Afrikaans\", \"af\"),\n\t\t\t\t\thuh.NewOption(\"wenyan — 文言文 + English technical terms\", \"wenyan\"),\n\t\t\t\t).\n\t\t\t\tValue(&language),\n\t\t).Title(\"4 / 5 · Language\"),\n\n\t\t// Step 5a: LangSmith toggle\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Enable LangSmith?\").\n\t\t\t\tDescription(\"LLM observability and trace collection\").\n\t\t\t\tAffirmative(\"Yes\").\n\t\t\t\tNegative(\"No\").\n\t\t\t\tValue(&useLangSmith),\n\t\t).Title(\"5 / 5 · Observability\"),\n\n\t\t// Step 5b: LangSmith key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LangSmith API Key\").\n\t\t\t\tPlaceholder(\"lsv2_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&langSmithKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"5 / 5 · LangSmith\").\n\t\t\tWithHideFunc(func() bool { return !useLangSmith }),\n\t).WithTheme(huh.ThemeFunc(ui.DecepticonTheme))\n\n\tif err := form.Run(); err != nil {\n\t\treturn fmt.Errorf(\"setup cancelled: %w\", err)\n\t}\n\n\t// Strict-mode gate: refuse to write .env when the user picked\n\t// Ollama but the host probe found no tool-capable model. The\n\t// in-form Note shows the same remediation; this is the boundary\n\t// guarantee that a broken setup never ships.\n\tif contains(methods, methodOllamaLocal) && len(ollamaProbe.ToolCapableModels) == 0 {\n\t\treturn ollamaUnusableError(ollamaProbe, ollamaAPIBase)\n\t}\n\n\t// huh.MultiSelect returns selected values in option order, not the\n\t// order the user toggled. Re-derive the priority by walking\n\t// methodOrder and keeping only what the user picked.\n\tpriority := make([]string, 0, len(methods))\n\tfor _, m := range methodOrder {\n\t\tif contains(methods, m) {\n\t\t\tpriority = append(priority, m)\n\t\t}\n\t}\n\n\tvalues := map[string]string{\n\t\t\"DECEPTICON_MODEL_PROFILE\": profile,\n\t\t\"DECEPTICON_LANGUAGE\": language,\n\t\t\"DECEPTICON_AUTH_PRIORITY\": strings.Join(priority, \",\"),\n\t\t\"DECEPTICON_AUTH_CLAUDE_CODE\": boolStr(contains(methods, methodAnthropicOAuth)),\n\t\t\"DECEPTICON_AUTH_CHATGPT\": boolStr(contains(methods, methodOpenAIOAuth)),\n\t\t\"DECEPTICON_AUTH_GEMINI\": boolStr(contains(methods, methodGoogleOAuth)),\n\t\t\"DECEPTICON_AUTH_GROK\": boolStr(contains(methods, methodGrokOAuth)),\n\t\t\"DECEPTICON_AUTH_COPILOT\": boolStr(contains(methods, methodCopilotOAuth)),\n\t\t\"DECEPTICON_AUTH_PERPLEXITY\": boolStr(contains(methods, methodPerplexityOAuth)),\n\t}\n\n\tif anthropicKey != \"\" {\n\t\tvalues[\"ANTHROPIC_API_KEY\"] = anthropicKey\n\t}\n\tif openaiKey != \"\" {\n\t\tvalues[\"OPENAI_API_KEY\"] = openaiKey\n\t}\n\tif geminiKey != \"\" {\n\t\tvalues[\"GEMINI_API_KEY\"] = geminiKey\n\t}\n\tif minimaxKey != \"\" {\n\t\tvalues[\"MINIMAX_API_KEY\"] = minimaxKey\n\t}\n\tif deepseekKey != \"\" {\n\t\tvalues[\"DEEPSEEK_API_KEY\"] = deepseekKey\n\t}\n\tif xaiKey != \"\" {\n\t\tvalues[\"XAI_API_KEY\"] = xaiKey\n\t}\n\tif mistralKey != \"\" {\n\t\tvalues[\"MISTRAL_API_KEY\"] = mistralKey\n\t}\n\tif openrouterKey != \"\" {\n\t\tvalues[\"OPENROUTER_API_KEY\"] = openrouterKey\n\t}\n\tif nvidiaKey != \"\" {\n\t\tvalues[\"NVIDIA_API_KEY\"] = nvidiaKey\n\t}\n\tif geminiSessionCookies != \"\" {\n\t\tvalues[\"GEMINI_SESSION_COOKIES\"] = geminiSessionCookies\n\t}\n\tif grokSessionToken != \"\" {\n\t\tvalues[\"GROK_SESSION_TOKEN\"] = grokSessionToken\n\t}\n\tif copilotRefreshToken != \"\" {\n\t\tvalues[\"COPILOT_REFRESH_TOKEN\"] = copilotRefreshToken\n\t}\n\tif perplexitySessionToken != \"\" {\n\t\tvalues[\"PERPLEXITY_SESSION_TOKEN\"] = perplexitySessionToken\n\t}\n\tif contains(methods, methodOllamaLocal) {\n\t\tvalues[\"OLLAMA_API_BASE\"] = strings.TrimSpace(ollamaAPIBase)\n\t\tvalues[\"OLLAMA_MODEL\"] = strings.TrimSpace(ollamaModel)\n\t}\n\tif contains(methods, methodOllamaCloud) {\n\t\tvalues[\"OLLAMA_CLOUD_API_BASE\"] = strings.TrimSpace(ollamaCloudAPIBase)\n\t\tvalues[\"OLLAMA_CLOUD_API_KEY\"] = strings.TrimSpace(ollamaCloudAPIKey)\n\t\tvalues[\"OLLAMA_CLOUD_MODEL\"] = strings.TrimSpace(ollamaCloudModel)\n\t}\n\tif contains(methods, methodBedrockAPI) {\n\t\tvalues[\"AWS_ACCESS_KEY_ID\"] = strings.TrimSpace(awsAccessKeyID)\n\t\tvalues[\"AWS_SECRET_ACCESS_KEY\"] = strings.TrimSpace(awsSecretAccessKey)\n\t\t// LiteLLM's Bedrock provider reads AWS_REGION_NAME, not the\n\t\t// boto3-standard AWS_REGION. Set both for SDK compatibility.\n\t\tvalues[\"AWS_REGION_NAME\"] = strings.TrimSpace(awsRegion)\n\t\tvalues[\"AWS_REGION\"] = strings.TrimSpace(awsRegion)\n\t}\n\tif contains(methods, methodVertexAPI) {\n\t\tvalues[\"GOOGLE_APPLICATION_CREDENTIALS\"] = strings.TrimSpace(vertexCredsPath)\n\t\tvalues[\"VERTEXAI_PROJECT\"] = strings.TrimSpace(vertexProject)\n\t\tvalues[\"VERTEXAI_LOCATION\"] = strings.TrimSpace(vertexLocation)\n\t}\n\tif contains(methods, methodAzureAPI) {\n\t\tvalues[\"AZURE_API_KEY\"] = strings.TrimSpace(azureAPIKey)\n\t\tvalues[\"AZURE_API_BASE\"] = strings.TrimSpace(azureAPIBase)\n\t\tvalues[\"AZURE_API_VERSION\"] = strings.TrimSpace(azureAPIVersion)\n\t}\n\tif githubToken != \"\" {\n\t\t// LiteLLM's github/ provider reads GITHUB_API_KEY; the GitHub\n\t\t// CLI + most docs use GITHUB_TOKEN. Set both so either path\n\t\t// works without operator surprise.\n\t\tvalues[\"GITHUB_TOKEN\"] = strings.TrimSpace(githubToken)\n\t\tvalues[\"GITHUB_API_KEY\"] = strings.TrimSpace(githubToken)\n\t}\n\tif groqKey != \"\" {\n\t\tvalues[\"GROQ_API_KEY\"] = strings.TrimSpace(groqKey)\n\t}\n\tif togetherKey != \"\" {\n\t\tvalues[\"TOGETHER_API_KEY\"] = strings.TrimSpace(togetherKey)\n\t}\n\tif fireworksKey != \"\" {\n\t\tvalues[\"FIREWORKS_API_KEY\"] = strings.TrimSpace(fireworksKey)\n\t}\n\tif cohereKey != \"\" {\n\t\tvalues[\"COHERE_API_KEY\"] = strings.TrimSpace(cohereKey)\n\t}\n\tif moonshotKey != \"\" {\n\t\tvalues[\"MOONSHOT_API_KEY\"] = strings.TrimSpace(moonshotKey)\n\t}\n\tif zaiKey != \"\" {\n\t\tvalues[\"ZAI_API_KEY\"] = strings.TrimSpace(zaiKey)\n\t}\n\tif dashscopeKey != \"\" {\n\t\tvalues[\"DASHSCOPE_API_KEY\"] = strings.TrimSpace(dashscopeKey)\n\t}\n\tif contains(methods, methodLMStudioLocal) {\n\t\tvalues[\"LMSTUDIO_API_BASE\"] = strings.TrimSpace(lmStudioAPIBase)\n\t\tvalues[\"LMSTUDIO_MODEL\"] = strings.TrimSpace(lmStudioModel)\n\t\t// LM Studio accepts any string as the API key; set a sentinel\n\t\t// so LiteLLM's openai-compatible shim doesn't fail an empty\n\t\t// Authorization header.\n\t\tvalues[\"LMSTUDIO_API_KEY\"] = \"lm-studio\"\n\t}\n\tif contains(methods, methodCustomOpenAIAPI) {\n\t\tvalues[\"CUSTOM_OPENAI_API_BASE\"] = strings.TrimSpace(customOpenAIAPIBase)\n\t\tvalues[\"CUSTOM_OPENAI_API_KEY\"] = strings.TrimSpace(customOpenAIAPIKey)\n\t\tvalues[\"CUSTOM_OPENAI_MODEL\"] = strings.TrimSpace(customOpenAIModel)\n\t}\n\n\tif useLangSmith && langSmithKey != \"\" {\n\t\tvalues[\"LANGSMITH_TRACING\"] = \"true\"\n\t\tvalues[\"LANGSMITH_API_KEY\"] = langSmithKey\n\t\tvalues[\"LANGSMITH_PROJECT\"] = \"decepticon\"\n\t}\n\n\tif err := config.WriteEnvFromEmbed(config.EnvPath(), values); err != nil {\n\t\treturn fmt.Errorf(\"write .env: %w\", err)\n\t}\n\n\t// Summary\n\tfmt.Println()\n\tfmt.Println(ui.Green.Render(\" ✓ Configuration saved\"))\n\tfmt.Println()\n\tfmt.Println(ui.Dim.Render(\" ┌──────────────────────────────────┐\"))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Methods \") + ui.Dim.Render(strings.Join(priority, \", \")))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Profile \") + ui.Dim.Render(profile))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Language \") + ui.Dim.Render(language))\n\tif useLangSmith {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" LangSmith \") + ui.Green.Render(\"enabled\"))\n\t}\n\tfmt.Println(ui.Dim.Render(\" │\"))\n\tfmt.Println(ui.Dim.Render(\" │ \") + ui.Dim.Render(config.EnvPath()))\n\tfmt.Println(ui.Dim.Render(\" └──────────────────────────────────┘\"))\n\tfmt.Println()\n\n\t// One-time GitHub star ask — the natural post-onboarding moment.\n\t// Suppressed on subsequent runs by the ack file at\n\t// $DECEPTICON_HOME/.starred, so a re-run of `decepticon onboard\n\t// --reset` does not re-prompt.\n\tstarprompt.PromptIfNotStarred()\n\n\tui.DimText(\" Run 'decepticon' to start the platform\")\n\treturn nil\n}\n\nfunc contains(haystack []string, needle string) bool {\n\treturn slices.Contains(haystack, needle)\n}\n\nfunc nonEmpty(s string) error {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn fmt.Errorf(\"value is required\")\n\t}\n\treturn nil\n}\n\nfunc boolStr(b bool) string {\n\tif b {\n\t\treturn \"true\"\n\t}\n\treturn \"false\"\n}\n\n// buildOllamaModelField returns the OLLAMA_MODEL form field that\n// matches the host probe outcome: strict Select when tool-capable\n// models are pulled, otherwise a remediation Note (different message\n// for the reachable-but-no-tools case vs unreachable Ollama).\nfunc buildOllamaModelField(probe ollamaProbeResult, selected *string) huh.Field {\n\tif len(probe.ToolCapableModels) > 0 {\n\t\toptions := make([]huh.Option[string], 0, len(probe.ToolCapableModels))\n\t\tfor _, m := range probe.ToolCapableModels {\n\t\t\toptions = append(options, huh.NewOption(m, m))\n\t\t}\n\t\tif !slices.Contains(probe.ToolCapableModels, *selected) {\n\t\t\t*selected = probe.ToolCapableModels[0]\n\t\t}\n\t\treturn huh.NewSelect[string]().\n\t\t\tTitle(\"OLLAMA_MODEL\").\n\t\t\tDescription(\"Tool-capable models found on your host. Decepticon\\nagents always emit tool calls — these are the only\\nmodels the wizard will accept.\").\n\t\t\tOptions(options...).\n\t\t\tValue(selected)\n\t}\n\n\tif probe.Reachable {\n\t\treturn huh.NewNote().\n\t\t\tTitle(\"OLLAMA_MODEL — no tool-capable models found\").\n\t\t\tDescription(\"Ollama is reachable but none of your pulled models\\nadvertise the 'tools' capability. Decepticon agents\\nalways emit tool calls, so a model without tool\\nsupport cannot power them.\\n\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n\t}\n\n\treturn huh.NewNote().\n\t\tTitle(\"OLLAMA_MODEL — Ollama not reachable\").\n\t\tDescription(\"Could not reach Ollama at \" + defaultOllamaAPIBase + \".\\n\\nMost likely Ollama isn't running or is bound to\\n127.0.0.1 only (which the Decepticon container can't\\nsee). Launch it on all interfaces, pull a tool-capable\\nmodel, then re-run the wizard:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n}\n\n// ollamaUnusableError surfaces the post-form remediation when the user\n// picked Ollama but the probe found nothing usable. Two flavors so the\n// hint matches whichever in-form Note was shown.\nfunc ollamaUnusableError(probe ollamaProbeResult, baseURL string) error {\n\tif probe.Reachable {\n\t\treturn fmt.Errorf(\n\t\t\t\"Ollama selected but no tool-capable models found on the host.\\n\" +\n\t\t\t\t\"Decepticon agents always emit tool calls — pull a tool-capable\\n\" +\n\t\t\t\t\"model and verify it advertises tools, then re-run:\\n\\n\" +\n\t\t\t\t\" ollama pull qwen3-coder:30b\\n\" +\n\t\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\" +\n\t\t\t\t\" decepticon onboard --reset\")\n\t}\n\treturn fmt.Errorf(\n\t\t\"Ollama selected but the host probe could not reach %s.\\n\"+\n\t\t\t\"Make sure Ollama is running and bound to all interfaces (the\\n\"+\n\t\t\t\"default 127.0.0.1 binding is invisible to containers), then\\n\"+\n\t\t\t\"pull a tool-capable model and re-run:\\n\\n\"+\n\t\t\t\" OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\"+\n\t\t\t\" ollama pull qwen3-coder:30b\\n\"+\n\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\"+\n\t\t\t\" decepticon onboard --reset\",\n\t\tbaseURL)\n}\n"}, "d0079b422a423b7fb96be7b4da2f50a205bdb99a": {"t": 1785755398.0068157, "n": 39852, "h": "9f59cec8abc0b137", "c": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/huh/v2\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/starprompt\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui\"\n\t\"github.com/spf13/cobra\"\n)\n\n// onboardOllamaProbeBudget bounds the wait for the background Ollama\n// discovery probe at form-construction time. Per-request timeout\n// (ollama_models.go) is much shorter; this is the worst-case across\n// all candidate URLs.\nconst onboardOllamaProbeBudget = 3 * time.Second\n\nvar resetFlag bool\n\nvar onboardCmd = &cobra.Command{\n\tUse: \"onboard\",\n\tShort: \"Configure Decepticon (auth methods, model profile, observability)\",\n\tRunE: runOnboard,\n}\n\nfunc init() {\n\tonboardCmd.Flags().BoolVar(&resetFlag, \"reset\", false, \"Reconfigure even if .env already exists\")\n\trootCmd.AddCommand(onboardCmd)\n}\n\n// AuthMethod identifiers — must match decepticon/llm/models.py::AuthMethod.\nconst (\n\tmethodAnthropicOAuth = \"anthropic_oauth\"\n\tmethodAnthropicAPI = \"anthropic_api\"\n\tmethodOpenAIOAuth = \"openai_oauth\"\n\tmethodOpenAIAPI = \"openai_api\"\n\tmethodGoogleOAuth = \"google_oauth\"\n\tmethodGoogleAPI = \"google_api\"\n\tmethodMiniMaxAPI = \"minimax_api\"\n\tmethodDeepSeekAPI = \"deepseek_api\"\n\tmethodXAIAPI = \"xai_api\"\n\tmethodGrokOAuth = \"grok_oauth\"\n\tmethodMistralAPI = \"mistral_api\"\n\tmethodOpenRouterAPI = \"openrouter_api\"\n\tmethodNvidiaAPI = \"nvidia_api\"\n\tmethodCopilotOAuth = \"copilot_oauth\"\n\tmethodPerplexityOAuth = \"perplexity_oauth\"\n\tmethodOllamaLocal = \"ollama_local\"\n\tmethodOllamaCloud = \"ollama_cloud\"\n\t// Cloud gateways added in the OpenClaude provider migration.\n\tmethodBedrockAPI = \"bedrock_api\"\n\tmethodVertexAPI = \"vertex_api\"\n\tmethodAzureAPI = \"azure_api\"\n\tmethodGroqAPI = \"groq_api\"\n\tmethodTogetherAPI = \"together_api\"\n\tmethodFireworksAPI = \"fireworks_api\"\n\tmethodCohereAPI = \"cohere_api\"\n\tmethodMoonshotAPI = \"moonshot_api\"\n\tmethodZaiAPI = \"zai_api\"\n\tmethodDashscopeAPI = \"dashscope_api\"\n\tmethodGitHubModelsAPI = \"github_models_api\"\n\tmethodLMStudioLocal = \"lmstudio_local\"\n\tmethodCustomOpenAIAPI = \"custom_openai_api\"\n)\n\n// Defaults shown in form placeholders for the new providers.\nconst (\n\tdefaultLMStudioAPIBase = \"http://host.docker.internal:1234/v1\"\n\tdefaultLMStudioModel = \"qwen2.5-coder-7b-instruct\"\n\tdefaultAzureAPIVersion = \"2024-08-01-preview\"\n)\n\n// Default Ollama wiring shown to OSS users. `host.docker.internal`\n// is the universal answer regardless of where Ollama runs (macOS host,\n// WSL2 distro, native Linux): from inside Decepticon's containers it\n// resolves to the host network namespace via the\n// `extra_hosts: [host.docker.internal:host-gateway]` entry on the\n// litellm service in docker-compose.yml. `localhost` is never the\n// right answer here — that's the container itself.\n//\n// The host's Ollama must additionally be bound to 0.0.0.0 (the default\n// 127.0.0.1 binding only accepts host-side connections); the wizard\n// surfaces this requirement to the user.\n//\n// The default model is the smallest/fastest one most laptops can\n// actually run; users with a GPU will pick something like qwen3-coder:30b.\nconst (\n\tdefaultOllamaAPIBase = \"http://host.docker.internal:11434\"\n\tdefaultOllamaModel = \"llama3.2\"\n)\n\n// methodOrder is the priority order surfaced in the wizard. The\n// resulting DECEPTICON_AUTH_PRIORITY preserves this order, filtered\n// to the methods the user actually selected. OAuth precedes the\n// matching API on purpose: a subscription primary should fall back\n// to the paid API only when the subscription quota is exhausted.\n// Ollama sits last: cloud providers are usually preferred when both\n// are configured, but a user wanting to lead with local-only inference\n// can reorder the priority manually in .env.\nvar methodOrder = []string{\n\tmethodAnthropicOAuth,\n\tmethodAnthropicAPI,\n\tmethodOpenAIOAuth,\n\tmethodOpenAIAPI,\n\tmethodGoogleOAuth,\n\tmethodGoogleAPI,\n\tmethodMiniMaxAPI,\n\tmethodDeepSeekAPI,\n\tmethodXAIAPI,\n\tmethodGrokOAuth,\n\tmethodMistralAPI,\n\tmethodOpenRouterAPI,\n\tmethodNvidiaAPI,\n\tmethodCopilotOAuth,\n\tmethodPerplexityOAuth,\n\t// Cloud gateways — anthropic-via-cloud paths first, then\n\t// other multi-vendor hubs.\n\tmethodBedrockAPI,\n\tmethodVertexAPI,\n\tmethodAzureAPI,\n\tmethodGitHubModelsAPI,\n\tmethodGroqAPI,\n\tmethodTogetherAPI,\n\tmethodFireworksAPI,\n\tmethodCohereAPI,\n\tmethodMoonshotAPI,\n\tmethodZaiAPI,\n\tmethodDashscopeAPI,\n\t// Local last so cloud-preferred default still picks remote\n\t// providers as primary; users can re-order in .env.\n\tmethodLMStudioLocal,\n\tmethodOllamaLocal,\n\tmethodOllamaCloud,\n\tmethodCustomOpenAIAPI,\n}\n\nfunc runOnboard(cmd *cobra.Command, args []string) error {\n\tif config.EnvExists() && !resetFlag {\n\t\tui.Info(\".env already configured at \" + config.EnvPath())\n\t\tui.DimText(\"Run 'decepticon onboard --reset' to reconfigure\")\n\t\treturn nil\n\t}\n\n\t// Kick off Ollama discovery in the background so the network\n\t// round-trip overlaps with huh's startup work; the OLLAMA_MODEL\n\t// field type depends on the result (Select vs remediation Note).\n\tprobeCh := make(chan ollamaProbeResult, 1)\n\tgo func() {\n\t\tprobeCh <- probeOllamaForOnboard(defaultOllamaAPIBase)\n\t}()\n\n\tvar (\n\t\tmethods []string\n\t\tanthropicKey string\n\t\topenaiKey string\n\t\tgeminiKey string\n\t\tminimaxKey string\n\t\tdeepseekKey string\n\t\txaiKey string\n\t\tmistralKey string\n\t\topenrouterKey string\n\t\tnvidiaKey string\n\t\tgeminiSessionCookies string\n\t\tcopilotRefreshToken string\n\t\tgrokSessionToken string\n\t\tperplexitySessionToken string\n\t\tollamaAPIBase = defaultOllamaAPIBase\n\t\tollamaModel = defaultOllamaModel\n\t\t// Cloud gateways\n\t\tawsAccessKeyID string\n\t\tawsSecretAccessKey string\n\t\tawsRegion = \"us-east-1\"\n\t\tvertexCredsPath string\n\t\tvertexProject string\n\t\tvertexLocation = \"us-central1\"\n\t\tazureAPIKey string\n\t\tazureAPIBase string\n\t\tazureAPIVersion = defaultAzureAPIVersion\n\t\tgroqKey string\n\t\ttogetherKey string\n\t\tfireworksKey string\n\t\tcohereKey string\n\t\tmoonshotKey string\n\t\tzaiKey string\n\t\tdashscopeKey string\n\t\tgithubToken string\n\t\t// Cloud Ollama\n\t\tollamaCloudAPIBase string\n\t\tollamaCloudAPIKey string\n\t\tollamaCloudModel string\n\t\t// LM Studio (local OpenAI-compatible)\n\t\tlmStudioAPIBase = defaultLMStudioAPIBase\n\t\tlmStudioModel = defaultLMStudioModel\n\t\t// Custom OpenAI-compatible endpoint\n\t\tcustomOpenAIAPIBase string\n\t\tcustomOpenAIAPIKey string\n\t\tcustomOpenAIModel string\n\t\tprofile string\n\t\tlanguage = \"en\"\n\t\tuseLangSmith bool\n\t\tlangSmithKey string\n\t)\n\t// Block on the probe (zero-value result on timeout means\n\t// \"unreachable\" — drops through to the remediation Note).\n\t// time.NewTimer + Stop avoids the time.After timer leak when the\n\t// probe finishes first.\n\tprobeTimer := time.NewTimer(onboardOllamaProbeBudget)\n\tvar ollamaProbe ollamaProbeResult\n\tselect {\n\tcase ollamaProbe = <-probeCh:\n\t\tprobeTimer.Stop()\n\tcase <-probeTimer.C:\n\t}\n\tollamaModelField := buildOllamaModelField(ollamaProbe, &ollamaModel)\n\n\tform := huh.NewForm(\n\t\t// Intro\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Decepticon Setup\").\n\t\t\t\tDescription(\"Configure auth methods, model profile, and\\nobservability.\\n\\nUse ↑↓ to navigate, space to toggle, Enter to confirm.\"),\n\t\t),\n\n\t\t// Step 1: Auth methods (multi-select)\n\t\thuh.NewGroup(\n\t\t\thuh.NewMultiSelect[string]().\n\t\t\t\tTitle(\"Auth Methods\").\n\t\t\t\tDescription(\"Press SPACE to toggle, ENTER to confirm.\\nUse ↑↓ to scroll, '/' to filter the list.\\nPick every credential you have — each is an\\nindependent fallback in priority order.\").\n\t\t\t\tFilterable(true).\n\t\t\t\tHeight(15).\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"Claude Code OAuth — Anthropic subscription (auth/*)\", methodAnthropicOAuth),\n\t\t\t\t\thuh.NewOption(\"Anthropic API Key — sk-ant-...\", methodAnthropicAPI),\n\t\t\t\t\thuh.NewOption(\"ChatGPT OAuth — ChatGPT Pro/Plus/Team subscription via `codex login` (auth/gpt-*)\", methodOpenAIOAuth),\n\t\t\t\t\thuh.NewOption(\"OpenAI API Key — sk-...\", methodOpenAIAPI),\n\t\t\t\t\thuh.NewOption(\"Google API Key — AIza... (Gemini)\", methodGoogleAPI),\n\t\t\t\t\thuh.NewOption(\"MiniMax API Key — eyJ...\", methodMiniMaxAPI),\n\t\t\t\t\thuh.NewOption(\"DeepSeek API Key — sk-...\", methodDeepSeekAPI),\n\t\t\t\t\thuh.NewOption(\"xAI API Key — xai-... (Grok)\", methodXAIAPI),\n\t\t\t\t\thuh.NewOption(\"Mistral API Key — (no fixed prefix)\", methodMistralAPI),\n\t\t\t\t\thuh.NewOption(\"OpenRouter API Key — sk-or-...\", methodOpenRouterAPI),\n\t\t\t\t\thuh.NewOption(\"Nvidia NIM API Key — nvapi-...\", methodNvidiaAPI),\n\t\t\t\t\thuh.NewOption(\"Gemini Advanced — Google One AI Premium subscription (gemini-sub/*)\", methodGoogleOAuth),\n\t\t\t\t\thuh.NewOption(\"SuperGrok — X Premium+ Grok subscription (grok-sub/*)\", methodGrokOAuth),\n\t\t\t\t\thuh.NewOption(\"GitHub Copilot Pro — Copilot subscription (copilot/*)\", methodCopilotOAuth),\n\t\t\t\t\thuh.NewOption(\"Perplexity Pro — Perplexity subscription (pplx-sub/*)\", methodPerplexityOAuth),\n\t\t\t\t\thuh.NewOption(\"AWS Bedrock — Anthropic models on AWS (bedrock/*)\", methodBedrockAPI),\n\t\t\t\t\thuh.NewOption(\"GCP Vertex AI — Anthropic + Gemini on GCP (vertex_ai/*)\", methodVertexAPI),\n\t\t\t\t\thuh.NewOption(\"Azure OpenAI — Azure-hosted GPT deployments (azure/*)\", methodAzureAPI),\n\t\t\t\t\thuh.NewOption(\"GitHub Models — GPT family via GitHub PAT (github/*)\", methodGitHubModelsAPI),\n\t\t\t\t\thuh.NewOption(\"Groq Cloud — LPU-accelerated Llama (groq/*)\", methodGroqAPI),\n\t\t\t\t\thuh.NewOption(\"Together AI — Llama / Mixtral hub (together_ai/*)\", methodTogetherAPI),\n\t\t\t\t\thuh.NewOption(\"Fireworks AI — Llama / Mixtral hub (fireworks_ai/*)\", methodFireworksAPI),\n\t\t\t\t\thuh.NewOption(\"Cohere Command — Command-A / Command-R (cohere/*)\", methodCohereAPI),\n\t\t\t\t\thuh.NewOption(\"Moonshot Kimi K2 — Kimi K2 (moonshot/*)\", methodMoonshotAPI),\n\t\t\t\t\thuh.NewOption(\"Z.ai GLM-4.5 — GLM-4.5 / GLM-4.5-Air (zai/*)\", methodZaiAPI),\n\t\t\t\t\thuh.NewOption(\"Alibaba DashScope — Qwen Max/Plus/Turbo (dashscope/*)\", methodDashscopeAPI),\n\t\t\t\t\thuh.NewOption(\"LM Studio (local) — local OpenAI-compatible server (lm_studio/*)\", methodLMStudioLocal),\n\t\t\t\t\thuh.NewOption(\"Local LLM (Ollama) — any pulled model, no API key\", methodOllamaLocal),\n\t\t\t\t\thuh.NewOption(\"Ollama Cloud — hosted Ollama (ollama_chat/* via cloud)\", methodOllamaCloud),\n\t\t\t\t\thuh.NewOption(\"Custom OpenAI Endpoint — bring-your-own OpenAI-compatible URL\", methodCustomOpenAIAPI),\n\t\t\t\t).\n\t\t\t\tValue(&methods).\n\t\t\t\tValidate(func(s []string) error {\n\t\t\t\t\tif len(s) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"select at least one credential\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"1 / 5 · Credentials\").\n\t\t\tDescription(\"Select all that apply\"),\n\n\t\t// Step 2a: Anthropic API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Anthropic API Key\").\n\t\t\t\tPlaceholder(\"sk-ant-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&anthropicKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Anthropic API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicAPI) }),\n\n\t\t// Step 2c: OpenAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenAI API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenAI API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIAPI) }),\n\n\t\t// Step 2c: Google API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Google (Gemini) API Key\").\n\t\t\t\tPlaceholder(\"AIza...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Google API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleAPI) }),\n\n\t\t// Step 2d: MiniMax API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MiniMax API Key\").\n\t\t\t\tPlaceholder(\"eyJ...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&minimaxKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · MiniMax API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMiniMaxAPI) }),\n\n\t\t// Step 2d-i: DeepSeek API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DeepSeek API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&deepseekKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DeepSeek API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDeepSeekAPI) }),\n\n\t\t// Step 2d-ii: xAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"xAI API Key\").\n\t\t\t\tPlaceholder(\"xai-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&xaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · xAI API (Grok)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodXAIAPI) }),\n\n\t\t// Step 2d-iii: Mistral API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Mistral API Key\").\n\t\t\t\tPlaceholder(\"paste your Mistral API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&mistralKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Mistral API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMistralAPI) }),\n\n\t\t// Step 2e: OpenRouter API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenRouter API Key\").\n\t\t\t\tPlaceholder(\"sk-or-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openrouterKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenRouter API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenRouterAPI) }),\n\n\t\t// Step 2f: Nvidia NIM API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Nvidia NIM API Key\").\n\t\t\t\tPlaceholder(\"nvapi-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&nvidiaKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Nvidia NIM API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodNvidiaAPI) }),\n\n\t\t// Step 2-oauth-i: Gemini Advanced subscription\n\t\t// Multi-cookie value (NID + Secure-1PSID + Secure-1PSIDTS, etc.)\n\t\t// joined with semicolons. Optional — power users can drop a\n\t\t// tokens.json under ~/.config/gemini/ instead and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Gemini Advanced Subscription\").\n\t\t\t\tDescription(\"Open gemini.google.com → DevTools → Application →\\nCookies → gemini.google.com. Copy the values of\\nNID, __Secure-1PSID, __Secure-1PSIDTS as a single\\nsemicolon-joined string. Or leave blank to use\\nGEMINI_ACCESS_TOKEN / ~/.config/gemini/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GEMINI_SESSION_COOKIES\").\n\t\t\t\tPlaceholder(\"NID=...; __Secure-1PSID=... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiSessionCookies),\n\t\t).Title(\"2 / 5 · Gemini Advanced\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleOAuth) }),\n\n\t\t// Step 2-oauth-ii: SuperGrok subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"SuperGrok Subscription\").\n\t\t\t\tDescription(\"Open grok.com → DevTools → Application →\\nCookies → grok.com → copy the value of\\n`auth_token`. Or leave blank to use\\nGROK_ACCESS_TOKEN / ~/.config/grok/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROK_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste auth_token cookie value (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&grokSessionToken),\n\t\t).Title(\"2 / 5 · SuperGrok\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGrokOAuth) }),\n\n\t\t// Step 2-oauth-iii: GitHub Copilot Pro subscription\n\t\t// Refresh token rotation, not a single session cookie. Devs can\n\t\t// extract one from VSCode's Copilot extension config or follow\n\t\t// gh-copilot-cli onboarding. Optional via tokens.json fallback.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GitHub Copilot Pro Subscription\").\n\t\t\t\tDescription(\"Provide a Copilot refresh token (long-lived, used\\nto rotate access tokens). Or leave blank to use\\nCOPILOT_ACCESS_TOKEN / ~/.config/copilot/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COPILOT_REFRESH_TOKEN\").\n\t\t\t\tPlaceholder(\"ghu_... or ghr_... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&copilotRefreshToken),\n\t\t).Title(\"2 / 5 · Copilot Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCopilotOAuth) }),\n\n\t\t// Step 2-oauth-iv: Perplexity Pro subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Perplexity Pro Subscription\").\n\t\t\t\tDescription(\"Open perplexity.ai → DevTools → Application →\\nCookies → perplexity.ai → copy the value of\\n`next-auth.session-token`. Or leave blank to use\\nPERPLEXITY_ACCESS_TOKEN / ~/.config/perplexity/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"PERPLEXITY_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste next-auth.session-token (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&perplexitySessionToken),\n\t\t).Title(\"2 / 5 · Perplexity Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodPerplexityOAuth) }),\n\n\t\t// Step 2g: Local Ollama. The OLLAMA_MODEL field is built from\n\t\t// the host probe (buildOllamaModelField): a strict Select when\n\t\t// tool-capable models are pulled, a remediation Note otherwise.\n\t\t// The post-form gate refuses to write .env in the Note cases.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Local Ollama\").\n\t\t\t\tDescription(\"Ollama must already be running on your host with\\nat least one tool-capable model pulled. Launch it\\nbound to all interfaces so the Decepticon container\\ncan reach it:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\\nThe default 127.0.0.1 binding only accepts host-side\\nconnections — containers won't see it.\\n\\nThe default URL `host.docker.internal:11434` works\\non macOS, Linux, and WSL2 (with or without Docker\\nDesktop). Only change it for remote / custom setups.\\nNote: the model list is probed against the default URL\\nat wizard start; if you customize OLLAMA_API_BASE,\\nfinish the wizard then re-run 'decepticon onboard\\n--reset' so the probe targets the new endpoint.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_API_BASE\").\n\t\t\t\tPlaceholder(defaultOllamaAPIBase).\n\t\t\t\tValue(&ollamaAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\tollamaModelField,\n\t\t).Title(\"2 / 5 · Local LLM (Ollama)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaLocal) }),\n\n\t\t// Step 2-cloud-i: AWS Bedrock — three-field group (key + secret + region)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"AWS Bedrock\").\n\t\t\t\tDescription(\"Bedrock uses AWS SigV4 — provide IAM access key,\\nsecret, and the region where Anthropic models are\\nenabled (us-east-1 or us-west-2 typically).\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_ACCESS_KEY_ID\").\n\t\t\t\tPlaceholder(\"AKIA...\").\n\t\t\t\tValue(&awsAccessKeyID).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_SECRET_ACCESS_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&awsSecretAccessKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_REGION\").\n\t\t\t\tPlaceholder(\"us-east-1\").\n\t\t\t\tValue(&awsRegion).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · AWS Bedrock\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodBedrockAPI) }),\n\n\t\t// Step 2-cloud-ii: GCP Vertex AI — service-account JSON path + project + region\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GCP Vertex AI\").\n\t\t\t\tDescription(\"Vertex AI uses a Google Cloud service-account JSON.\\nDownload one from IAM & Admin → Service Accounts and\\npaste the absolute file path. Project + region must\\nmatch where you enabled Anthropic + Gemini models.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GOOGLE_APPLICATION_CREDENTIALS\").\n\t\t\t\tPlaceholder(\"/abs/path/to/service-account.json\").\n\t\t\t\tValue(&vertexCredsPath).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"VERTEXAI_PROJECT\").\n\t\t\t\tPlaceholder(\"my-gcp-project-id\").\n\t\t\t\tValue(&vertexProject).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"VERTEXAI_LOCATION\").\n\t\t\t\tPlaceholder(\"us-central1\").\n\t\t\t\tValue(&vertexLocation).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · GCP Vertex AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodVertexAPI) }),\n\n\t\t// Step 2-cloud-iii: Azure OpenAI — key + endpoint + version\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Azure OpenAI Service\").\n\t\t\t\tDescription(\"Endpoint is your azure-openai resource URL,\\ne.g. https://my-resource.openai.azure.com.\\nThe deployment names map 1:1 to gpt-5.5/gpt-5.4/...\\nso the model matrix in DF resolves out of the box.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&azureAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_BASE\").\n\t\t\t\tPlaceholder(\"https://<resource>.openai.azure.com\").\n\t\t\t\tValue(&azureAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_VERSION\").\n\t\t\t\tPlaceholder(defaultAzureAPIVersion).\n\t\t\t\tValue(&azureAPIVersion).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Azure OpenAI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAzureAPI) }),\n\n\t\t// Step 2-cloud-iv: GitHub Models — PAT\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GITHUB_TOKEN\").\n\t\t\t\tDescription(\"Fine-grained PAT with the 'models' permission scope.\\nGenerate at github.com/settings/personal-access-tokens.\").\n\t\t\t\tPlaceholder(\"github_pat_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&githubToken).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · GitHub Models\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGitHubModelsAPI) }),\n\n\t\t// Step 2-cloud-v: Groq\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROQ_API_KEY\").\n\t\t\t\tPlaceholder(\"gsk_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&groqKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Groq Cloud\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGroqAPI) }),\n\n\t\t// Step 2-cloud-vi: Together AI\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"TOGETHER_API_KEY\").\n\t\t\t\tPlaceholder(\"paste your Together AI API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&togetherKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Together AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodTogetherAPI) }),\n\n\t\t// Step 2-cloud-vii: Fireworks AI\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"FIREWORKS_API_KEY\").\n\t\t\t\tPlaceholder(\"fw_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&fireworksKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Fireworks AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodFireworksAPI) }),\n\n\t\t// Step 2-cloud-viii: Cohere\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COHERE_API_KEY\").\n\t\t\t\tPlaceholder(\"paste your Cohere API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&cohereKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Cohere\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCohereAPI) }),\n\n\t\t// Step 2-cloud-ix: Moonshot Kimi K2\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MOONSHOT_API_KEY\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&moonshotKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Moonshot Kimi K2\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMoonshotAPI) }),\n\n\t\t// Step 2-cloud-x: Z.ai GLM-4.5\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"ZAI_API_KEY\").\n\t\t\t\tDescription(\"Z.ai is OpenAI-compatible — base URL is hard-wired to\\nhttps://api.z.ai/api/paas/v4. Get a key at z.ai/manage.\").\n\t\t\t\tPlaceholder(\"paste your Z.ai API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&zaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Z.ai GLM-4.5\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodZaiAPI) }),\n\n\t\t// Step 2-cloud-xi: Alibaba DashScope (Qwen)\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DASHSCOPE_API_KEY\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&dashscopeKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DashScope (Qwen)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDashscopeAPI) }),\n\n\t\t// Step 2-local-ii: LM Studio (local OpenAI-compatible)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"LM Studio\").\n\t\t\t\tDescription(\"LM Studio runs a local OpenAI-compatible server.\\nLaunch the LM Studio app and start the server\\n(Developer tab → Start). The default port is 1234.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LMSTUDIO_API_BASE\").\n\t\t\t\tPlaceholder(defaultLMStudioAPIBase).\n\t\t\t\tValue(&lmStudioAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LMSTUDIO_MODEL\").\n\t\t\t\tDescription(\"Model identifier as shown in LM Studio (e.g.\\nqwen2.5-coder-7b-instruct).\").\n\t\t\t\tPlaceholder(defaultLMStudioModel).\n\t\t\t\tValue(&lmStudioModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · LM Studio\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodLMStudioLocal) }),\n\n\t\t// Step 2-local-iii: Ollama Cloud (hosted)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Ollama Cloud\").\n\t\t\t\tDescription(\"Hosted Ollama — same /api/chat tool-calling endpoint\\nas local Ollama, just behind an API key.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_API_BASE\").\n\t\t\t\tPlaceholder(\"https://api.ollama.com/v1\").\n\t\t\t\tValue(&ollamaCloudAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&ollamaCloudAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_MODEL\").\n\t\t\t\tPlaceholder(\"llama3.3:70b\").\n\t\t\t\tValue(&ollamaCloudModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Ollama Cloud\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaCloud) }),\n\n\t\t// Step 2-custom: Custom OpenAI-compatible endpoint\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Custom OpenAI-compatible Endpoint\").\n\t\t\t\tDescription(\"Point at any OpenAI-compatible server (gateways,\\nself-hosted vLLM, internal LiteLLM, etc.). The model\\nname is sent verbatim to the upstream — match what\\nyour gateway exposes.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_API_BASE\").\n\t\t\t\tPlaceholder(\"https://gateway.example.com/v1\").\n\t\t\t\tValue(&customOpenAIAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&customOpenAIAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_MODEL\").\n\t\t\t\tPlaceholder(\"gpt-4o-mini\").\n\t\t\t\tValue(&customOpenAIModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Custom OpenAI Endpoint\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCustomOpenAIAPI) }),\n\n\t\t// Step 3: Model profile\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Model Profile\").\n\t\t\t\tDescription(\"eco per-agent tier (recommended)\\nmax every agent on HIGH (expensive)\\ntest every agent on LOW (development)\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"eco — per-agent tier (recommended)\", \"eco\"),\n\t\t\t\t\thuh.NewOption(\"max — every agent on HIGH (expensive)\", \"max\"),\n\t\t\t\t\thuh.NewOption(\"test — every agent on LOW (development)\", \"test\"),\n\t\t\t\t).\n\t\t\t\tValue(&profile),\n\t\t).Title(\"3 / 5 · Profile\"),\n\n\t\t// Step 4: Language\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Agent Language\").\n\t\t\t\tDescription(\"Language for all agent prose output (menus, questions,\\nsummaries, errors). Technical output stays in English.\\nCountry codes (dk, se, jp, cn) are auto-resolved.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"en — English (default)\", \"en\"),\n\t\t\t\t\thuh.NewOption(\"no — Norwegian\", \"no\"),\n\t\t\t\t\thuh.NewOption(\"da — Danish\", \"da\"),\n\t\t\t\t\thuh.NewOption(\"sv — Swedish\", \"sv\"),\n\t\t\t\t\thuh.NewOption(\"fi — Finnish\", \"fi\"),\n\t\t\t\t\thuh.NewOption(\"is — Icelandic\", \"is\"),\n\t\t\t\t\thuh.NewOption(\"ko — Korean\", \"ko\"),\n\t\t\t\t\thuh.NewOption(\"ja — Japanese\", \"ja\"),\n\t\t\t\t\thuh.NewOption(\"zh — Chinese\", \"zh\"),\n\t\t\t\t\thuh.NewOption(\"zh-tw — Traditional Chinese\", \"zh-tw\"),\n\t\t\t\t\thuh.NewOption(\"es — Spanish\", \"es\"),\n\t\t\t\t\thuh.NewOption(\"pt — Portuguese\", \"pt\"),\n\t\t\t\t\thuh.NewOption(\"pt-br — Brazilian Portuguese\", \"pt-br\"),\n\t\t\t\t\thuh.NewOption(\"de — German\", \"de\"),\n\t\t\t\t\thuh.NewOption(\"fr — French\", \"fr\"),\n\t\t\t\t\thuh.NewOption(\"nl — Dutch\", \"nl\"),\n\t\t\t\t\thuh.NewOption(\"it — Italian\", \"it\"),\n\t\t\t\t\thuh.NewOption(\"pl — Polish\", \"pl\"),\n\t\t\t\t\thuh.NewOption(\"cs — Czech\", \"cs\"),\n\t\t\t\t\thuh.NewOption(\"uk — Ukrainian\", \"uk\"),\n\t\t\t\t\thuh.NewOption(\"ro — Romanian\", \"ro\"),\n\t\t\t\t\thuh.NewOption(\"hr — Croatian\", \"hr\"),\n\t\t\t\t\thuh.NewOption(\"bg — Bulgarian\", \"bg\"),\n\t\t\t\t\thuh.NewOption(\"ru — Russian\", \"ru\"),\n\t\t\t\t\thuh.NewOption(\"el — Greek\", \"el\"),\n\t\t\t\t\thuh.NewOption(\"hu — Hungarian\", \"hu\"),\n\t\t\t\t\thuh.NewOption(\"tr — Turkish\", \"tr\"),\n\t\t\t\t\thuh.NewOption(\"ar — Arabic\", \"ar\"),\n\t\t\t\t\thuh.NewOption(\"fa — Persian\", \"fa\"),\n\t\t\t\t\thuh.NewOption(\"he — Hebrew\", \"he\"),\n\t\t\t\t\thuh.NewOption(\"hi — Hindi\", \"hi\"),\n\t\t\t\t\thuh.NewOption(\"th — Thai\", \"th\"),\n\t\t\t\t\thuh.NewOption(\"vi — Vietnamese\", \"vi\"),\n\t\t\t\t\thuh.NewOption(\"id — Indonesian\", \"id\"),\n\t\t\t\t\thuh.NewOption(\"ms — Malay\", \"ms\"),\n\t\t\t\t\thuh.NewOption(\"tl — Filipino\", \"tl\"),\n\t\t\t\t\thuh.NewOption(\"sw — Swahili\", \"sw\"),\n\t\t\t\t\thuh.NewOption(\"af — Afrikaans\", \"af\"),\n\t\t\t\t\thuh.NewOption(\"wenyan — 文言文 + English technical terms\", \"wenyan\"),\n\t\t\t\t).\n\t\t\t\tValue(&language),\n\t\t).Title(\"4 / 5 · Language\"),\n\n\t\t// Step 5a: LangSmith toggle\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Enable LangSmith?\").\n\t\t\t\tDescription(\"LLM observability and trace collection\").\n\t\t\t\tAffirmative(\"Yes\").\n\t\t\t\tNegative(\"No\").\n\t\t\t\tValue(&useLangSmith),\n\t\t).Title(\"5 / 5 · Observability\"),\n\n\t\t// Step 5b: LangSmith key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LangSmith API Key\").\n\t\t\t\tPlaceholder(\"lsv2_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&langSmithKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"5 / 5 · LangSmith\").\n\t\t\tWithHideFunc(func() bool { return !useLangSmith }),\n\t).WithTheme(huh.ThemeFunc(ui.DecepticonTheme))\n\n\tif err := form.Run(); err != nil {\n\t\treturn fmt.Errorf(\"setup cancelled: %w\", err)\n\t}\n\n\t// Strict-mode gate: refuse to write .env when the user picked\n\t// Ollama but the host probe found no tool-capable model. The\n\t// in-form Note shows the same remediation; this is the boundary\n\t// guarantee that a broken setup never ships.\n\tif contains(methods, methodOllamaLocal) && len(ollamaProbe.ToolCapableModels) == 0 {\n\t\treturn ollamaUnusableError(ollamaProbe, ollamaAPIBase)\n\t}\n\n\t// huh.MultiSelect returns selected values in option order, not the\n\t// order the user toggled. Re-derive the priority by walking\n\t// methodOrder and keeping only what the user picked.\n\tpriority := make([]string, 0, len(methods))\n\tfor _, m := range methodOrder {\n\t\tif contains(methods, m) {\n\t\t\tpriority = append(priority, m)\n\t\t}\n\t}\n\n\tvalues := map[string]string{\n\t\t\"DECEPTICON_MODEL_PROFILE\": profile,\n\t\t\"DECEPTICON_LANGUAGE\": language,\n\t\t\"DECEPTICON_AUTH_PRIORITY\": strings.Join(priority, \",\"),\n\t\t\"DECEPTICON_AUTH_CLAUDE_CODE\": boolStr(contains(methods, methodAnthropicOAuth)),\n\t\t\"DECEPTICON_AUTH_CHATGPT\": boolStr(contains(methods, methodOpenAIOAuth)),\n\t\t\"DECEPTICON_AUTH_GEMINI\": boolStr(contains(methods, methodGoogleOAuth)),\n\t\t\"DECEPTICON_AUTH_GROK\": boolStr(contains(methods, methodGrokOAuth)),\n\t\t\"DECEPTICON_AUTH_COPILOT\": boolStr(contains(methods, methodCopilotOAuth)),\n\t\t\"DECEPTICON_AUTH_PERPLEXITY\": boolStr(contains(methods, methodPerplexityOAuth)),\n\t}\n\n\tif anthropicKey != \"\" {\n\t\tvalues[\"ANTHROPIC_API_KEY\"] = anthropicKey\n\t}\n\tif openaiKey != \"\" {\n\t\tvalues[\"OPENAI_API_KEY\"] = openaiKey\n\t}\n\tif geminiKey != \"\" {\n\t\tvalues[\"GEMINI_API_KEY\"] = geminiKey\n\t}\n\tif minimaxKey != \"\" {\n\t\tvalues[\"MINIMAX_API_KEY\"] = minimaxKey\n\t}\n\tif deepseekKey != \"\" {\n\t\tvalues[\"DEEPSEEK_API_KEY\"] = deepseekKey\n\t}\n\tif xaiKey != \"\" {\n\t\tvalues[\"XAI_API_KEY\"] = xaiKey\n\t}\n\tif mistralKey != \"\" {\n\t\tvalues[\"MISTRAL_API_KEY\"] = mistralKey\n\t}\n\tif openrouterKey != \"\" {\n\t\tvalues[\"OPENROUTER_API_KEY\"] = openrouterKey\n\t}\n\tif nvidiaKey != \"\" {\n\t\tvalues[\"NVIDIA_API_KEY\"] = nvidiaKey\n\t}\n\tif geminiSessionCookies != \"\" {\n\t\tvalues[\"GEMINI_SESSION_COOKIES\"] = geminiSessionCookies\n\t}\n\tif grokSessionToken != \"\" {\n\t\tvalues[\"GROK_SESSION_TOKEN\"] = grokSessionToken\n\t}\n\tif copilotRefreshToken != \"\" {\n\t\tvalues[\"COPILOT_REFRESH_TOKEN\"] = copilotRefreshToken\n\t}\n\tif perplexitySessionToken != \"\" {\n\t\tvalues[\"PERPLEXITY_SESSION_TOKEN\"] = perplexitySessionToken\n\t}\n\tif contains(methods, methodOllamaLocal) {\n\t\tvalues[\"OLLAMA_API_BASE\"] = strings.TrimSpace(ollamaAPIBase)\n\t\tvalues[\"OLLAMA_MODEL\"] = strings.TrimSpace(ollamaModel)\n\t}\n\tif contains(methods, methodOllamaCloud) {\n\t\tvalues[\"OLLAMA_CLOUD_API_BASE\"] = strings.TrimSpace(ollamaCloudAPIBase)\n\t\tvalues[\"OLLAMA_CLOUD_API_KEY\"] = strings.TrimSpace(ollamaCloudAPIKey)\n\t\tvalues[\"OLLAMA_CLOUD_MODEL\"] = strings.TrimSpace(ollamaCloudModel)\n\t}\n\tif contains(methods, methodBedrockAPI) {\n\t\tvalues[\"AWS_ACCESS_KEY_ID\"] = strings.TrimSpace(awsAccessKeyID)\n\t\tvalues[\"AWS_SECRET_ACCESS_KEY\"] = strings.TrimSpace(awsSecretAccessKey)\n\t\t// LiteLLM's Bedrock provider reads AWS_REGION_NAME, not the\n\t\t// boto3-standard AWS_REGION. Set both for SDK compatibility.\n\t\tvalues[\"AWS_REGION_NAME\"] = strings.TrimSpace(awsRegion)\n\t\tvalues[\"AWS_REGION\"] = strings.TrimSpace(awsRegion)\n\t}\n\tif contains(methods, methodVertexAPI) {\n\t\tvalues[\"GOOGLE_APPLICATION_CREDENTIALS\"] = strings.TrimSpace(vertexCredsPath)\n\t\tvalues[\"VERTEXAI_PROJECT\"] = strings.TrimSpace(vertexProject)\n\t\tvalues[\"VERTEXAI_LOCATION\"] = strings.TrimSpace(vertexLocation)\n\t}\n\tif contains(methods, methodAzureAPI) {\n\t\tvalues[\"AZURE_API_KEY\"] = strings.TrimSpace(azureAPIKey)\n\t\tvalues[\"AZURE_API_BASE\"] = strings.TrimSpace(azureAPIBase)\n\t\tvalues[\"AZURE_API_VERSION\"] = strings.TrimSpace(azureAPIVersion)\n\t}\n\tif githubToken != \"\" {\n\t\t// LiteLLM's github/ provider reads GITHUB_API_KEY; the GitHub\n\t\t// CLI + most docs use GITHUB_TOKEN. Set both so either path\n\t\t// works without operator surprise.\n\t\tvalues[\"GITHUB_TOKEN\"] = strings.TrimSpace(githubToken)\n\t\tvalues[\"GITHUB_API_KEY\"] = strings.TrimSpace(githubToken)\n\t}\n\tif groqKey != \"\" {\n\t\tvalues[\"GROQ_API_KEY\"] = strings.TrimSpace(groqKey)\n\t}\n\tif togetherKey != \"\" {\n\t\tvalues[\"TOGETHER_API_KEY\"] = strings.TrimSpace(togetherKey)\n\t}\n\tif fireworksKey != \"\" {\n\t\tvalues[\"FIREWORKS_API_KEY\"] = strings.TrimSpace(fireworksKey)\n\t}\n\tif cohereKey != \"\" {\n\t\tvalues[\"COHERE_API_KEY\"] = strings.TrimSpace(cohereKey)\n\t}\n\tif moonshotKey != \"\" {\n\t\tvalues[\"MOONSHOT_API_KEY\"] = strings.TrimSpace(moonshotKey)\n\t}\n\tif zaiKey != \"\" {\n\t\tvalues[\"ZAI_API_KEY\"] = strings.TrimSpace(zaiKey)\n\t}\n\tif dashscopeKey != \"\" {\n\t\tvalues[\"DASHSCOPE_API_KEY\"] = strings.TrimSpace(dashscopeKey)\n\t}\n\tif contains(methods, methodLMStudioLocal) {\n\t\tvalues[\"LMSTUDIO_API_BASE\"] = strings.TrimSpace(lmStudioAPIBase)\n\t\tvalues[\"LMSTUDIO_MODEL\"] = strings.TrimSpace(lmStudioModel)\n\t\t// LM Studio accepts any string as the API key; set a sentinel\n\t\t// so LiteLLM's openai-compatible shim doesn't fail an empty\n\t\t// Authorization header.\n\t\tvalues[\"LMSTUDIO_API_KEY\"] = \"lm-studio\"\n\t}\n\tif contains(methods, methodCustomOpenAIAPI) {\n\t\tvalues[\"CUSTOM_OPENAI_API_BASE\"] = strings.TrimSpace(customOpenAIAPIBase)\n\t\tvalues[\"CUSTOM_OPENAI_API_KEY\"] = strings.TrimSpace(customOpenAIAPIKey)\n\t\tvalues[\"CUSTOM_OPENAI_MODEL\"] = strings.TrimSpace(customOpenAIModel)\n\t}\n\n\tif useLangSmith && langSmithKey != \"\" {\n\t\tvalues[\"LANGSMITH_TRACING\"] = \"true\"\n\t\tvalues[\"LANGSMITH_API_KEY\"] = langSmithKey\n\t\tvalues[\"LANGSMITH_PROJECT\"] = \"decepticon\"\n\t}\n\n\tif err := config.WriteEnvFromEmbed(config.EnvPath(), values); err != nil {\n\t\treturn fmt.Errorf(\"write .env: %w\", err)\n\t}\n\n\t// Summary\n\tfmt.Println()\n\tfmt.Println(ui.Green.Render(\" ✓ Configuration saved\"))\n\tfmt.Println()\n\tfmt.Println(ui.Dim.Render(\" ┌──────────────────────────────────┐\"))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Methods \") + ui.Dim.Render(strings.Join(priority, \", \")))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Profile \") + ui.Dim.Render(profile))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Language \") + ui.Dim.Render(language))\n\tif useLangSmith {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" LangSmith \") + ui.Green.Render(\"enabled\"))\n\t}\n\tfmt.Println(ui.Dim.Render(\" │\"))\n\tfmt.Println(ui.Dim.Render(\" │ \") + ui.Dim.Render(config.EnvPath()))\n\tfmt.Println(ui.Dim.Render(\" └──────────────────────────────────┘\"))\n\tfmt.Println()\n\n\t// One-time GitHub star ask — the natural post-onboarding moment.\n\t// Suppressed on subsequent runs by the ack file at\n\t// $DECEPTICON_HOME/.starred, so a re-run of `decepticon onboard\n\t// --reset` does not re-prompt.\n\tstarprompt.PromptIfNotStarred()\n\n\tui.DimText(\" Run 'decepticon' to start the platform\")\n\treturn nil\n}\n\nfunc contains(haystack []string, needle string) bool {\n\treturn slices.Contains(haystack, needle)\n}\n\nfunc nonEmpty(s string) error {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn fmt.Errorf(\"value is required\")\n\t}\n\treturn nil\n}\n\nfunc boolStr(b bool) string {\n\tif b {\n\t\treturn \"true\"\n\t}\n\treturn \"false\"\n}\n\n// buildOllamaModelField returns the OLLAMA_MODEL form field that\n// matches the host probe outcome: strict Select when tool-capable\n// models are pulled, otherwise a remediation Note (different message\n// for the reachable-but-no-tools case vs unreachable Ollama).\nfunc buildOllamaModelField(probe ollamaProbeResult, selected *string) huh.Field {\n\tif len(probe.ToolCapableModels) > 0 {\n\t\toptions := make([]huh.Option[string], 0, len(probe.ToolCapableModels))\n\t\tfor _, m := range probe.ToolCapableModels {\n\t\t\toptions = append(options, huh.NewOption(m, m))\n\t\t}\n\t\tif !slices.Contains(probe.ToolCapableModels, *selected) {\n\t\t\t*selected = probe.ToolCapableModels[0]\n\t\t}\n\t\treturn huh.NewSelect[string]().\n\t\t\tTitle(\"OLLAMA_MODEL\").\n\t\t\tDescription(\"Tool-capable models found on your host. Decepticon\\nagents always emit tool calls — these are the only\\nmodels the wizard will accept.\").\n\t\t\tOptions(options...).\n\t\t\tValue(selected)\n\t}\n\n\tif probe.Reachable {\n\t\treturn huh.NewNote().\n\t\t\tTitle(\"OLLAMA_MODEL — no tool-capable models found\").\n\t\t\tDescription(\"Ollama is reachable but none of your pulled models\\nadvertise the 'tools' capability. Decepticon agents\\nalways emit tool calls, so a model without tool\\nsupport cannot power them.\\n\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n\t}\n\n\treturn huh.NewNote().\n\t\tTitle(\"OLLAMA_MODEL — Ollama not reachable\").\n\t\tDescription(\"Could not reach Ollama at \" + defaultOllamaAPIBase + \".\\n\\nMost likely Ollama isn't running or is bound to\\n127.0.0.1 only (which the Decepticon container can't\\nsee). Launch it on all interfaces, pull a tool-capable\\nmodel, then re-run the wizard:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n}\n\n// ollamaUnusableError surfaces the post-form remediation when the user\n// picked Ollama but the probe found nothing usable. Two flavors so the\n// hint matches whichever in-form Note was shown.\nfunc ollamaUnusableError(probe ollamaProbeResult, baseURL string) error {\n\tif probe.Reachable {\n\t\treturn fmt.Errorf(\n\t\t\t\"Ollama selected but no tool-capable models found on the host.\\n\" +\n\t\t\t\t\"Decepticon agents always emit tool calls — pull a tool-capable\\n\" +\n\t\t\t\t\"model and verify it advertises tools, then re-run:\\n\\n\" +\n\t\t\t\t\" ollama pull qwen3-coder:30b\\n\" +\n\t\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\" +\n\t\t\t\t\" decepticon onboard --reset\")\n\t}\n\treturn fmt.Errorf(\n\t\t\"Ollama selected but the host probe could not reach %s.\\n\"+\n\t\t\t\"Make sure Ollama is running and bound to all interfaces (the\\n\"+\n\t\t\t\"default 127.0.0.1 binding is invisible to containers), then\\n\"+\n\t\t\t\"pull a tool-capable model and re-run:\\n\\n\"+\n\t\t\t\" OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\"+\n\t\t\t\" ollama pull qwen3-coder:30b\\n\"+\n\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\"+\n\t\t\t\" decepticon onboard --reset\",\n\t\tbaseURL)\n}\n"}, "860eb849e47e382954d45fd985698753fb037c71": {"t": 1785755399.8138027, "n": 39524, "h": "8f89b4837a17ea88", "c": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/huh/v2\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui\"\n\t\"github.com/spf13/cobra\"\n)\n\n// onboardOllamaProbeBudget bounds the wait for the background Ollama\n// discovery probe at form-construction time. Per-request timeout\n// (ollama_models.go) is much shorter; this is the worst-case across\n// all candidate URLs.\nconst onboardOllamaProbeBudget = 3 * time.Second\n\nvar resetFlag bool\n\nvar onboardCmd = &cobra.Command{\n\tUse: \"onboard\",\n\tShort: \"Configure Decepticon (auth methods, model profile, observability)\",\n\tRunE: runOnboard,\n}\n\nfunc init() {\n\tonboardCmd.Flags().BoolVar(&resetFlag, \"reset\", false, \"Reconfigure even if .env already exists\")\n\trootCmd.AddCommand(onboardCmd)\n}\n\n// AuthMethod identifiers — must match decepticon/llm/models.py::AuthMethod.\nconst (\n\tmethodAnthropicOAuth = \"anthropic_oauth\"\n\tmethodAnthropicAPI = \"anthropic_api\"\n\tmethodOpenAIOAuth = \"openai_oauth\"\n\tmethodOpenAIAPI = \"openai_api\"\n\tmethodGoogleOAuth = \"google_oauth\"\n\tmethodGoogleAPI = \"google_api\"\n\tmethodMiniMaxAPI = \"minimax_api\"\n\tmethodDeepSeekAPI = \"deepseek_api\"\n\tmethodXAIAPI = \"xai_api\"\n\tmethodGrokOAuth = \"grok_oauth\"\n\tmethodMistralAPI = \"mistral_api\"\n\tmethodOpenRouterAPI = \"openrouter_api\"\n\tmethodNvidiaAPI = \"nvidia_api\"\n\tmethodCopilotOAuth = \"copilot_oauth\"\n\tmethodPerplexityOAuth = \"perplexity_oauth\"\n\tmethodOllamaLocal = \"ollama_local\"\n\tmethodOllamaCloud = \"ollama_cloud\"\n\t// Cloud gateways added in the OpenClaude provider migration.\n\tmethodBedrockAPI = \"bedrock_api\"\n\tmethodVertexAPI = \"vertex_api\"\n\tmethodAzureAPI = \"azure_api\"\n\tmethodGroqAPI = \"groq_api\"\n\tmethodTogetherAPI = \"together_api\"\n\tmethodFireworksAPI = \"fireworks_api\"\n\tmethodCohereAPI = \"cohere_api\"\n\tmethodMoonshotAPI = \"moonshot_api\"\n\tmethodZaiAPI = \"zai_api\"\n\tmethodDashscopeAPI = \"dashscope_api\"\n\tmethodGitHubModelsAPI = \"github_models_api\"\n\tmethodLMStudioLocal = \"lmstudio_local\"\n\tmethodCustomOpenAIAPI = \"custom_openai_api\"\n)\n\n// Defaults shown in form placeholders for the new providers.\nconst (\n\tdefaultLMStudioAPIBase = \"http://host.docker.internal:1234/v1\"\n\tdefaultLMStudioModel = \"qwen2.5-coder-7b-instruct\"\n\tdefaultAzureAPIVersion = \"2024-08-01-preview\"\n)\n\n// Default Ollama wiring shown to OSS users. `host.docker.internal`\n// is the universal answer regardless of where Ollama runs (macOS host,\n// WSL2 distro, native Linux): from inside Decepticon's containers it\n// resolves to the host network namespace via the\n// `extra_hosts: [host.docker.internal:host-gateway]` entry on the\n// litellm service in docker-compose.yml. `localhost` is never the\n// right answer here — that's the container itself.\n//\n// The host's Ollama must additionally be bound to 0.0.0.0 (the default\n// 127.0.0.1 binding only accepts host-side connections); the wizard\n// surfaces this requirement to the user.\n//\n// The default model is the smallest/fastest one most laptops can\n// actually run; users with a GPU will pick something like qwen3-coder:30b.\nconst (\n\tdefaultOllamaAPIBase = \"http://host.docker.internal:11434\"\n\tdefaultOllamaModel = \"llama3.2\"\n)\n\n// methodOrder is the priority order surfaced in the wizard. The\n// resulting DECEPTICON_AUTH_PRIORITY preserves this order, filtered\n// to the methods the user actually selected. OAuth precedes the\n// matching API on purpose: a subscription primary should fall back\n// to the paid API only when the subscription quota is exhausted.\n// Ollama sits last: cloud providers are usually preferred when both\n// are configured, but a user wanting to lead with local-only inference\n// can reorder the priority manually in .env.\nvar methodOrder = []string{\n\tmethodAnthropicOAuth,\n\tmethodAnthropicAPI,\n\tmethodOpenAIOAuth,\n\tmethodOpenAIAPI,\n\tmethodGoogleOAuth,\n\tmethodGoogleAPI,\n\tmethodMiniMaxAPI,\n\tmethodDeepSeekAPI,\n\tmethodXAIAPI,\n\tmethodGrokOAuth,\n\tmethodMistralAPI,\n\tmethodOpenRouterAPI,\n\tmethodNvidiaAPI,\n\tmethodCopilotOAuth,\n\tmethodPerplexityOAuth,\n\t// Cloud gateways — anthropic-via-cloud paths first, then\n\t// other multi-vendor hubs.\n\tmethodBedrockAPI,\n\tmethodVertexAPI,\n\tmethodAzureAPI,\n\tmethodGitHubModelsAPI,\n\tmethodGroqAPI,\n\tmethodTogetherAPI,\n\tmethodFireworksAPI,\n\tmethodCohereAPI,\n\tmethodMoonshotAPI,\n\tmethodZaiAPI,\n\tmethodDashscopeAPI,\n\t// Local last so cloud-preferred default still picks remote\n\t// providers as primary; users can re-order in .env.\n\tmethodLMStudioLocal,\n\tmethodOllamaLocal,\n\tmethodOllamaCloud,\n\tmethodCustomOpenAIAPI,\n}\n\nfunc runOnboard(cmd *cobra.Command, args []string) error {\n\tif config.EnvExists() && !resetFlag {\n\t\tui.Info(\".env already configured at \" + config.EnvPath())\n\t\tui.DimText(\"Run 'decepticon onboard --reset' to reconfigure\")\n\t\treturn nil\n\t}\n\n\t// Kick off Ollama discovery in the background so the network\n\t// round-trip overlaps with huh's startup work; the OLLAMA_MODEL\n\t// field type depends on the result (Select vs remediation Note).\n\tprobeCh := make(chan ollamaProbeResult, 1)\n\tgo func() {\n\t\tprobeCh <- probeOllamaForOnboard(defaultOllamaAPIBase)\n\t}()\n\n\tvar (\n\t\tmethods []string\n\t\tanthropicKey string\n\t\topenaiKey string\n\t\tgeminiKey string\n\t\tminimaxKey string\n\t\tdeepseekKey string\n\t\txaiKey string\n\t\tmistralKey string\n\t\topenrouterKey string\n\t\tnvidiaKey string\n\t\tgeminiSessionCookies string\n\t\tcopilotRefreshToken string\n\t\tgrokSessionToken string\n\t\tperplexitySessionToken string\n\t\tollamaAPIBase = defaultOllamaAPIBase\n\t\tollamaModel = defaultOllamaModel\n\t\t// Cloud gateways\n\t\tawsAccessKeyID string\n\t\tawsSecretAccessKey string\n\t\tawsRegion = \"us-east-1\"\n\t\tvertexCredsPath string\n\t\tvertexProject string\n\t\tvertexLocation = \"us-central1\"\n\t\tazureAPIKey string\n\t\tazureAPIBase string\n\t\tazureAPIVersion = defaultAzureAPIVersion\n\t\tgroqKey string\n\t\ttogetherKey string\n\t\tfireworksKey string\n\t\tcohereKey string\n\t\tmoonshotKey string\n\t\tzaiKey string\n\t\tdashscopeKey string\n\t\tgithubToken string\n\t\t// Cloud Ollama\n\t\tollamaCloudAPIBase string\n\t\tollamaCloudAPIKey string\n\t\tollamaCloudModel string\n\t\t// LM Studio (local OpenAI-compatible)\n\t\tlmStudioAPIBase = defaultLMStudioAPIBase\n\t\tlmStudioModel = defaultLMStudioModel\n\t\t// Custom OpenAI-compatible endpoint\n\t\tcustomOpenAIAPIBase string\n\t\tcustomOpenAIAPIKey string\n\t\tcustomOpenAIModel string\n\t\tprofile string\n\t\tlanguage = \"en\"\n\t\tuseLangSmith bool\n\t\tlangSmithKey string\n\t)\n\t// Block on the probe (zero-value result on timeout means\n\t// \"unreachable\" — drops through to the remediation Note).\n\t// time.NewTimer + Stop avoids the time.After timer leak when the\n\t// probe finishes first.\n\tprobeTimer := time.NewTimer(onboardOllamaProbeBudget)\n\tvar ollamaProbe ollamaProbeResult\n\tselect {\n\tcase ollamaProbe = <-probeCh:\n\t\tprobeTimer.Stop()\n\tcase <-probeTimer.C:\n\t}\n\tollamaModelField := buildOllamaModelField(ollamaProbe, &ollamaModel)\n\n\tform := huh.NewForm(\n\t\t// Intro\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Decepticon Setup\").\n\t\t\t\tDescription(\"Configure auth methods, model profile, and\\nobservability.\\n\\nUse ↑↓ to navigate, space to toggle, Enter to confirm.\"),\n\t\t),\n\n\t\t// Step 1: Auth methods (multi-select)\n\t\thuh.NewGroup(\n\t\t\thuh.NewMultiSelect[string]().\n\t\t\t\tTitle(\"Auth Methods\").\n\t\t\t\tDescription(\"Press SPACE to toggle, ENTER to confirm.\\nUse ↑↓ to scroll, '/' to filter the list.\\nPick every credential you have — each is an\\nindependent fallback in priority order.\").\n\t\t\t\tFilterable(true).\n\t\t\t\tHeight(15).\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"Claude Code OAuth — Anthropic subscription (auth/*)\", methodAnthropicOAuth),\n\t\t\t\t\thuh.NewOption(\"Anthropic API Key — sk-ant-...\", methodAnthropicAPI),\n\t\t\t\t\thuh.NewOption(\"ChatGPT OAuth — ChatGPT Pro/Plus/Team subscription via `codex login` (auth/gpt-*)\", methodOpenAIOAuth),\n\t\t\t\t\thuh.NewOption(\"OpenAI API Key — sk-...\", methodOpenAIAPI),\n\t\t\t\t\thuh.NewOption(\"Google API Key — AIza... (Gemini)\", methodGoogleAPI),\n\t\t\t\t\thuh.NewOption(\"MiniMax API Key — eyJ...\", methodMiniMaxAPI),\n\t\t\t\t\thuh.NewOption(\"DeepSeek API Key — sk-...\", methodDeepSeekAPI),\n\t\t\t\t\thuh.NewOption(\"xAI API Key — xai-... (Grok)\", methodXAIAPI),\n\t\t\t\t\thuh.NewOption(\"Mistral API Key — (no fixed prefix)\", methodMistralAPI),\n\t\t\t\t\thuh.NewOption(\"OpenRouter API Key — sk-or-...\", methodOpenRouterAPI),\n\t\t\t\t\thuh.NewOption(\"Nvidia NIM API Key — nvapi-...\", methodNvidiaAPI),\n\t\t\t\t\thuh.NewOption(\"Gemini Advanced — Google One AI Premium subscription (gemini-sub/*)\", methodGoogleOAuth),\n\t\t\t\t\thuh.NewOption(\"SuperGrok — X Premium+ Grok subscription (grok-sub/*)\", methodGrokOAuth),\n\t\t\t\t\thuh.NewOption(\"GitHub Copilot Pro — Copilot subscription (copilot/*)\", methodCopilotOAuth),\n\t\t\t\t\thuh.NewOption(\"Perplexity Pro — Perplexity subscription (pplx-sub/*)\", methodPerplexityOAuth),\n\t\t\t\t\thuh.NewOption(\"AWS Bedrock — Anthropic models on AWS (bedrock/*)\", methodBedrockAPI),\n\t\t\t\t\thuh.NewOption(\"GCP Vertex AI — Anthropic + Gemini on GCP (vertex_ai/*)\", methodVertexAPI),\n\t\t\t\t\thuh.NewOption(\"Azure OpenAI — Azure-hosted GPT deployments (azure/*)\", methodAzureAPI),\n\t\t\t\t\thuh.NewOption(\"GitHub Models — GPT family via GitHub PAT (github/*)\", methodGitHubModelsAPI),\n\t\t\t\t\thuh.NewOption(\"Groq Cloud — LPU-accelerated Llama (groq/*)\", methodGroqAPI),\n\t\t\t\t\thuh.NewOption(\"Together AI — Llama / Mixtral hub (together_ai/*)\", methodTogetherAPI),\n\t\t\t\t\thuh.NewOption(\"Fireworks AI — Llama / Mixtral hub (fireworks_ai/*)\", methodFireworksAPI),\n\t\t\t\t\thuh.NewOption(\"Cohere Command — Command-A / Command-R (cohere/*)\", methodCohereAPI),\n\t\t\t\t\thuh.NewOption(\"Moonshot Kimi K2 — Kimi K2 (moonshot/*)\", methodMoonshotAPI),\n\t\t\t\t\thuh.NewOption(\"Z.ai GLM-4.5 — GLM-4.5 / GLM-4.5-Air (zai/*)\", methodZaiAPI),\n\t\t\t\t\thuh.NewOption(\"Alibaba DashScope — Qwen Max/Plus/Turbo (dashscope/*)\", methodDashscopeAPI),\n\t\t\t\t\thuh.NewOption(\"LM Studio (local) — local OpenAI-compatible server (lm_studio/*)\", methodLMStudioLocal),\n\t\t\t\t\thuh.NewOption(\"Local LLM (Ollama) — any pulled model, no API key\", methodOllamaLocal),\n\t\t\t\t\thuh.NewOption(\"Ollama Cloud — hosted Ollama (ollama_chat/* via cloud)\", methodOllamaCloud),\n\t\t\t\t\thuh.NewOption(\"Custom OpenAI Endpoint — bring-your-own OpenAI-compatible URL\", methodCustomOpenAIAPI),\n\t\t\t\t).\n\t\t\t\tValue(&methods).\n\t\t\t\tValidate(func(s []string) error {\n\t\t\t\t\tif len(s) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"select at least one credential\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"1 / 5 · Credentials\").\n\t\t\tDescription(\"Select all that apply\"),\n\n\t\t// Step 2a: Anthropic API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Anthropic API Key\").\n\t\t\t\tPlaceholder(\"sk-ant-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&anthropicKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Anthropic API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicAPI) }),\n\n\t\t// Step 2c: OpenAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenAI API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenAI API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIAPI) }),\n\n\t\t// Step 2c: Google API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Google (Gemini) API Key\").\n\t\t\t\tPlaceholder(\"AIza...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Google API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleAPI) }),\n\n\t\t// Step 2d: MiniMax API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MiniMax API Key\").\n\t\t\t\tPlaceholder(\"eyJ...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&minimaxKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · MiniMax API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMiniMaxAPI) }),\n\n\t\t// Step 2d-i: DeepSeek API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DeepSeek API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&deepseekKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DeepSeek API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDeepSeekAPI) }),\n\n\t\t// Step 2d-ii: xAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"xAI API Key\").\n\t\t\t\tPlaceholder(\"xai-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&xaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · xAI API (Grok)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodXAIAPI) }),\n\n\t\t// Step 2d-iii: Mistral API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Mistral API Key\").\n\t\t\t\tPlaceholder(\"paste your Mistral API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&mistralKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Mistral API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMistralAPI) }),\n\n\t\t// Step 2e: OpenRouter API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenRouter API Key\").\n\t\t\t\tPlaceholder(\"sk-or-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openrouterKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenRouter API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenRouterAPI) }),\n\n\t\t// Step 2f: Nvidia NIM API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Nvidia NIM API Key\").\n\t\t\t\tPlaceholder(\"nvapi-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&nvidiaKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Nvidia NIM API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodNvidiaAPI) }),\n\n\t\t// Step 2-oauth-i: Gemini Advanced subscription\n\t\t// Multi-cookie value (NID + Secure-1PSID + Secure-1PSIDTS, etc.)\n\t\t// joined with semicolons. Optional — power users can drop a\n\t\t// tokens.json under ~/.config/gemini/ instead and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Gemini Advanced Subscription\").\n\t\t\t\tDescription(\"Open gemini.google.com → DevTools → Application →\\nCookies → gemini.google.com. Copy the values of\\nNID, __Secure-1PSID, __Secure-1PSIDTS as a single\\nsemicolon-joined string. Or leave blank to use\\nGEMINI_ACCESS_TOKEN / ~/.config/gemini/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GEMINI_SESSION_COOKIES\").\n\t\t\t\tPlaceholder(\"NID=...; __Secure-1PSID=... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiSessionCookies),\n\t\t).Title(\"2 / 5 · Gemini Advanced\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleOAuth) }),\n\n\t\t// Step 2-oauth-ii: SuperGrok subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"SuperGrok Subscription\").\n\t\t\t\tDescription(\"Open grok.com → DevTools → Application →\\nCookies → grok.com → copy the value of\\n`auth_token`. Or leave blank to use\\nGROK_ACCESS_TOKEN / ~/.config/grok/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROK_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste auth_token cookie value (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&grokSessionToken),\n\t\t).Title(\"2 / 5 · SuperGrok\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGrokOAuth) }),\n\n\t\t// Step 2-oauth-iii: GitHub Copilot Pro subscription\n\t\t// Refresh token rotation, not a single session cookie. Devs can\n\t\t// extract one from VSCode's Copilot extension config or follow\n\t\t// gh-copilot-cli onboarding. Optional via tokens.json fallback.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GitHub Copilot Pro Subscription\").\n\t\t\t\tDescription(\"Provide a Copilot refresh token (long-lived, used\\nto rotate access tokens). Or leave blank to use\\nCOPILOT_ACCESS_TOKEN / ~/.config/copilot/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COPILOT_REFRESH_TOKEN\").\n\t\t\t\tPlaceholder(\"ghu_... or ghr_... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&copilotRefreshToken),\n\t\t).Title(\"2 / 5 · Copilot Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCopilotOAuth) }),\n\n\t\t// Step 2-oauth-iv: Perplexity Pro subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Perplexity Pro Subscription\").\n\t\t\t\tDescription(\"Open perplexity.ai → DevTools → Application →\\nCookies → perplexity.ai → copy the value of\\n`next-auth.session-token`. Or leave blank to use\\nPERPLEXITY_ACCESS_TOKEN / ~/.config/perplexity/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"PERPLEXITY_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste next-auth.session-token (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&perplexitySessionToken),\n\t\t).Title(\"2 / 5 · Perplexity Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodPerplexityOAuth) }),\n\n\t\t// Step 2g: Local Ollama. The OLLAMA_MODEL field is built from\n\t\t// the host probe (buildOllamaModelField): a strict Select when\n\t\t// tool-capable models are pulled, a remediation Note otherwise.\n\t\t// The post-form gate refuses to write .env in the Note cases.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Local Ollama\").\n\t\t\t\tDescription(\"Ollama must already be running on your host with\\nat least one tool-capable model pulled. Launch it\\nbound to all interfaces so the Decepticon container\\ncan reach it:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\\nThe default 127.0.0.1 binding only accepts host-side\\nconnections — containers won't see it.\\n\\nThe default URL `host.docker.internal:11434` works\\non macOS, Linux, and WSL2 (with or without Docker\\nDesktop). Only change it for remote / custom setups.\\nNote: the model list is probed against the default URL\\nat wizard start; if you customize OLLAMA_API_BASE,\\nfinish the wizard then re-run 'decepticon onboard\\n--reset' so the probe targets the new endpoint.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_API_BASE\").\n\t\t\t\tPlaceholder(defaultOllamaAPIBase).\n\t\t\t\tValue(&ollamaAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\tollamaModelField,\n\t\t).Title(\"2 / 5 · Local LLM (Ollama)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaLocal) }),\n\n\t\t// Step 2-cloud-i: AWS Bedrock — three-field group (key + secret + region)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"AWS Bedrock\").\n\t\t\t\tDescription(\"Bedrock uses AWS SigV4 — provide IAM access key,\\nsecret, and the region where Anthropic models are\\nenabled (us-east-1 or us-west-2 typically).\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_ACCESS_KEY_ID\").\n\t\t\t\tPlaceholder(\"AKIA...\").\n\t\t\t\tValue(&awsAccessKeyID).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_SECRET_ACCESS_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&awsSecretAccessKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_REGION\").\n\t\t\t\tPlaceholder(\"us-east-1\").\n\t\t\t\tValue(&awsRegion).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · AWS Bedrock\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodBedrockAPI) }),\n\n\t\t// Step 2-cloud-ii: GCP Vertex AI — service-account JSON path + project + region\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GCP Vertex AI\").\n\t\t\t\tDescription(\"Vertex AI uses a Google Cloud service-account JSON.\\nDownload one from IAM & Admin → Service Accounts and\\npaste the absolute file path. Project + region must\\nmatch where you enabled Anthropic + Gemini models.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GOOGLE_APPLICATION_CREDENTIALS\").\n\t\t\t\tPlaceholder(\"/abs/path/to/service-account.json\").\n\t\t\t\tValue(&vertexCredsPath).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"VERTEXAI_PROJECT\").\n\t\t\t\tPlaceholder(\"my-gcp-project-id\").\n\t\t\t\tValue(&vertexProject).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"VERTEXAI_LOCATION\").\n\t\t\t\tPlaceholder(\"us-central1\").\n\t\t\t\tValue(&vertexLocation).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · GCP Vertex AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodVertexAPI) }),\n\n\t\t// Step 2-cloud-iii: Azure OpenAI — key + endpoint + version\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Azure OpenAI Service\").\n\t\t\t\tDescription(\"Endpoint is your azure-openai resource URL,\\ne.g. https://my-resource.openai.azure.com.\\nThe deployment names map 1:1 to gpt-5.5/gpt-5.4/...\\nso the model matrix in DF resolves out of the box.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&azureAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_BASE\").\n\t\t\t\tPlaceholder(\"https://<resource>.openai.azure.com\").\n\t\t\t\tValue(&azureAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_VERSION\").\n\t\t\t\tPlaceholder(defaultAzureAPIVersion).\n\t\t\t\tValue(&azureAPIVersion).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Azure OpenAI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAzureAPI) }),\n\n\t\t// Step 2-cloud-iv: GitHub Models — PAT\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GITHUB_TOKEN\").\n\t\t\t\tDescription(\"Fine-grained PAT with the 'models' permission scope.\\nGenerate at github.com/settings/personal-access-tokens.\").\n\t\t\t\tPlaceholder(\"github_pat_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&githubToken).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · GitHub Models\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGitHubModelsAPI) }),\n\n\t\t// Step 2-cloud-v: Groq\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROQ_API_KEY\").\n\t\t\t\tPlaceholder(\"gsk_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&groqKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Groq Cloud\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGroqAPI) }),\n\n\t\t// Step 2-cloud-vi: Together AI\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"TOGETHER_API_KEY\").\n\t\t\t\tPlaceholder(\"paste your Together AI API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&togetherKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Together AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodTogetherAPI) }),\n\n\t\t// Step 2-cloud-vii: Fireworks AI\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"FIREWORKS_API_KEY\").\n\t\t\t\tPlaceholder(\"fw_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&fireworksKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Fireworks AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodFireworksAPI) }),\n\n\t\t// Step 2-cloud-viii: Cohere\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COHERE_API_KEY\").\n\t\t\t\tPlaceholder(\"paste your Cohere API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&cohereKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Cohere\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCohereAPI) }),\n\n\t\t// Step 2-cloud-ix: Moonshot Kimi K2\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MOONSHOT_API_KEY\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&moonshotKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Moonshot Kimi K2\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMoonshotAPI) }),\n\n\t\t// Step 2-cloud-x: Z.ai GLM-4.5\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"ZAI_API_KEY\").\n\t\t\t\tDescription(\"Z.ai is OpenAI-compatible — base URL is hard-wired to\\nhttps://api.z.ai/api/paas/v4. Get a key at z.ai/manage.\").\n\t\t\t\tPlaceholder(\"paste your Z.ai API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&zaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Z.ai GLM-4.5\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodZaiAPI) }),\n\n\t\t// Step 2-cloud-xi: Alibaba DashScope (Qwen)\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DASHSCOPE_API_KEY\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&dashscopeKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DashScope (Qwen)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDashscopeAPI) }),\n\n\t\t// Step 2-local-ii: LM Studio (local OpenAI-compatible)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"LM Studio\").\n\t\t\t\tDescription(\"LM Studio runs a local OpenAI-compatible server.\\nLaunch the LM Studio app and start the server\\n(Developer tab → Start). The default port is 1234.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LMSTUDIO_API_BASE\").\n\t\t\t\tPlaceholder(defaultLMStudioAPIBase).\n\t\t\t\tValue(&lmStudioAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LMSTUDIO_MODEL\").\n\t\t\t\tDescription(\"Model identifier as shown in LM Studio (e.g.\\nqwen2.5-coder-7b-instruct).\").\n\t\t\t\tPlaceholder(defaultLMStudioModel).\n\t\t\t\tValue(&lmStudioModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · LM Studio\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodLMStudioLocal) }),\n\n\t\t// Step 2-local-iii: Ollama Cloud (hosted)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Ollama Cloud\").\n\t\t\t\tDescription(\"Hosted Ollama — same /api/chat tool-calling endpoint\\nas local Ollama, just behind an API key.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_API_BASE\").\n\t\t\t\tPlaceholder(\"https://api.ollama.com/v1\").\n\t\t\t\tValue(&ollamaCloudAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&ollamaCloudAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_MODEL\").\n\t\t\t\tPlaceholder(\"llama3.3:70b\").\n\t\t\t\tValue(&ollamaCloudModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Ollama Cloud\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaCloud) }),\n\n\t\t// Step 2-custom: Custom OpenAI-compatible endpoint\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Custom OpenAI-compatible Endpoint\").\n\t\t\t\tDescription(\"Point at any OpenAI-compatible server (gateways,\\nself-hosted vLLM, internal LiteLLM, etc.). The model\\nname is sent verbatim to the upstream — match what\\nyour gateway exposes.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_API_BASE\").\n\t\t\t\tPlaceholder(\"https://gateway.example.com/v1\").\n\t\t\t\tValue(&customOpenAIAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&customOpenAIAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_MODEL\").\n\t\t\t\tPlaceholder(\"gpt-4o-mini\").\n\t\t\t\tValue(&customOpenAIModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Custom OpenAI Endpoint\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCustomOpenAIAPI) }),\n\n\t\t// Step 3: Model profile\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Model Profile\").\n\t\t\t\tDescription(\"eco per-agent tier (recommended)\\nmax every agent on HIGH (expensive)\\ntest every agent on LOW (development)\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"eco — per-agent tier (recommended)\", \"eco\"),\n\t\t\t\t\thuh.NewOption(\"max — every agent on HIGH (expensive)\", \"max\"),\n\t\t\t\t\thuh.NewOption(\"test — every agent on LOW (development)\", \"test\"),\n\t\t\t\t).\n\t\t\t\tValue(&profile),\n\t\t).Title(\"3 / 5 · Profile\"),\n\n\t\t// Step 4: Language\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Agent Language\").\n\t\t\t\tDescription(\"Language for all agent prose output (menus, questions,\\nsummaries, errors). Technical output stays in English.\\nCountry codes (dk, se, jp, cn) are auto-resolved.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"en — English (default)\", \"en\"),\n\t\t\t\t\thuh.NewOption(\"no — Norwegian\", \"no\"),\n\t\t\t\t\thuh.NewOption(\"da — Danish\", \"da\"),\n\t\t\t\t\thuh.NewOption(\"sv — Swedish\", \"sv\"),\n\t\t\t\t\thuh.NewOption(\"fi — Finnish\", \"fi\"),\n\t\t\t\t\thuh.NewOption(\"is — Icelandic\", \"is\"),\n\t\t\t\t\thuh.NewOption(\"ko — Korean\", \"ko\"),\n\t\t\t\t\thuh.NewOption(\"ja — Japanese\", \"ja\"),\n\t\t\t\t\thuh.NewOption(\"zh — Chinese\", \"zh\"),\n\t\t\t\t\thuh.NewOption(\"zh-tw — Traditional Chinese\", \"zh-tw\"),\n\t\t\t\t\thuh.NewOption(\"es — Spanish\", \"es\"),\n\t\t\t\t\thuh.NewOption(\"pt — Portuguese\", \"pt\"),\n\t\t\t\t\thuh.NewOption(\"pt-br — Brazilian Portuguese\", \"pt-br\"),\n\t\t\t\t\thuh.NewOption(\"de — German\", \"de\"),\n\t\t\t\t\thuh.NewOption(\"fr — French\", \"fr\"),\n\t\t\t\t\thuh.NewOption(\"nl — Dutch\", \"nl\"),\n\t\t\t\t\thuh.NewOption(\"it — Italian\", \"it\"),\n\t\t\t\t\thuh.NewOption(\"pl — Polish\", \"pl\"),\n\t\t\t\t\thuh.NewOption(\"cs — Czech\", \"cs\"),\n\t\t\t\t\thuh.NewOption(\"uk — Ukrainian\", \"uk\"),\n\t\t\t\t\thuh.NewOption(\"ro — Romanian\", \"ro\"),\n\t\t\t\t\thuh.NewOption(\"hr — Croatian\", \"hr\"),\n\t\t\t\t\thuh.NewOption(\"bg — Bulgarian\", \"bg\"),\n\t\t\t\t\thuh.NewOption(\"ru — Russian\", \"ru\"),\n\t\t\t\t\thuh.NewOption(\"el — Greek\", \"el\"),\n\t\t\t\t\thuh.NewOption(\"hu — Hungarian\", \"hu\"),\n\t\t\t\t\thuh.NewOption(\"tr — Turkish\", \"tr\"),\n\t\t\t\t\thuh.NewOption(\"ar — Arabic\", \"ar\"),\n\t\t\t\t\thuh.NewOption(\"fa — Persian\", \"fa\"),\n\t\t\t\t\thuh.NewOption(\"he — Hebrew\", \"he\"),\n\t\t\t\t\thuh.NewOption(\"hi — Hindi\", \"hi\"),\n\t\t\t\t\thuh.NewOption(\"th — Thai\", \"th\"),\n\t\t\t\t\thuh.NewOption(\"vi — Vietnamese\", \"vi\"),\n\t\t\t\t\thuh.NewOption(\"id — Indonesian\", \"id\"),\n\t\t\t\t\thuh.NewOption(\"ms — Malay\", \"ms\"),\n\t\t\t\t\thuh.NewOption(\"tl — Filipino\", \"tl\"),\n\t\t\t\t\thuh.NewOption(\"sw — Swahili\", \"sw\"),\n\t\t\t\t\thuh.NewOption(\"af — Afrikaans\", \"af\"),\n\t\t\t\t\thuh.NewOption(\"wenyan — 文言文 + English technical terms\", \"wenyan\"),\n\t\t\t\t).\n\t\t\t\tValue(&language),\n\t\t).Title(\"4 / 5 · Language\"),\n\n\t\t// Step 5a: LangSmith toggle\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Enable LangSmith?\").\n\t\t\t\tDescription(\"LLM observability and trace collection\").\n\t\t\t\tAffirmative(\"Yes\").\n\t\t\t\tNegative(\"No\").\n\t\t\t\tValue(&useLangSmith),\n\t\t).Title(\"5 / 5 · Observability\"),\n\n\t\t// Step 5b: LangSmith key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LangSmith API Key\").\n\t\t\t\tPlaceholder(\"lsv2_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&langSmithKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"5 / 5 · LangSmith\").\n\t\t\tWithHideFunc(func() bool { return !useLangSmith }),\n\t).WithTheme(huh.ThemeFunc(ui.DecepticonTheme))\n\n\tif err := form.Run(); err != nil {\n\t\treturn fmt.Errorf(\"setup cancelled: %w\", err)\n\t}\n\n\t// Strict-mode gate: refuse to write .env when the user picked\n\t// Ollama but the host probe found no tool-capable model. The\n\t// in-form Note shows the same remediation; this is the boundary\n\t// guarantee that a broken setup never ships.\n\tif contains(methods, methodOllamaLocal) && len(ollamaProbe.ToolCapableModels) == 0 {\n\t\treturn ollamaUnusableError(ollamaProbe, ollamaAPIBase)\n\t}\n\n\t// huh.MultiSelect returns selected values in option order, not the\n\t// order the user toggled. Re-derive the priority by walking\n\t// methodOrder and keeping only what the user picked.\n\tpriority := make([]string, 0, len(methods))\n\tfor _, m := range methodOrder {\n\t\tif contains(methods, m) {\n\t\t\tpriority = append(priority, m)\n\t\t}\n\t}\n\n\tvalues := map[string]string{\n\t\t\"DECEPTICON_MODEL_PROFILE\": profile,\n\t\t\"DECEPTICON_LANGUAGE\": language,\n\t\t\"DECEPTICON_AUTH_PRIORITY\": strings.Join(priority, \",\"),\n\t\t\"DECEPTICON_AUTH_CLAUDE_CODE\": boolStr(contains(methods, methodAnthropicOAuth)),\n\t\t\"DECEPTICON_AUTH_CHATGPT\": boolStr(contains(methods, methodOpenAIOAuth)),\n\t\t\"DECEPTICON_AUTH_GEMINI\": boolStr(contains(methods, methodGoogleOAuth)),\n\t\t\"DECEPTICON_AUTH_GROK\": boolStr(contains(methods, methodGrokOAuth)),\n\t\t\"DECEPTICON_AUTH_COPILOT\": boolStr(contains(methods, methodCopilotOAuth)),\n\t\t\"DECEPTICON_AUTH_PERPLEXITY\": boolStr(contains(methods, methodPerplexityOAuth)),\n\t}\n\n\tif anthropicKey != \"\" {\n\t\tvalues[\"ANTHROPIC_API_KEY\"] = anthropicKey\n\t}\n\tif openaiKey != \"\" {\n\t\tvalues[\"OPENAI_API_KEY\"] = openaiKey\n\t}\n\tif geminiKey != \"\" {\n\t\tvalues[\"GEMINI_API_KEY\"] = geminiKey\n\t}\n\tif minimaxKey != \"\" {\n\t\tvalues[\"MINIMAX_API_KEY\"] = minimaxKey\n\t}\n\tif deepseekKey != \"\" {\n\t\tvalues[\"DEEPSEEK_API_KEY\"] = deepseekKey\n\t}\n\tif xaiKey != \"\" {\n\t\tvalues[\"XAI_API_KEY\"] = xaiKey\n\t}\n\tif mistralKey != \"\" {\n\t\tvalues[\"MISTRAL_API_KEY\"] = mistralKey\n\t}\n\tif openrouterKey != \"\" {\n\t\tvalues[\"OPENROUTER_API_KEY\"] = openrouterKey\n\t}\n\tif nvidiaKey != \"\" {\n\t\tvalues[\"NVIDIA_API_KEY\"] = nvidiaKey\n\t}\n\tif geminiSessionCookies != \"\" {\n\t\tvalues[\"GEMINI_SESSION_COOKIES\"] = geminiSessionCookies\n\t}\n\tif grokSessionToken != \"\" {\n\t\tvalues[\"GROK_SESSION_TOKEN\"] = grokSessionToken\n\t}\n\tif copilotRefreshToken != \"\" {\n\t\tvalues[\"COPILOT_REFRESH_TOKEN\"] = copilotRefreshToken\n\t}\n\tif perplexitySessionToken != \"\" {\n\t\tvalues[\"PERPLEXITY_SESSION_TOKEN\"] = perplexitySessionToken\n\t}\n\tif contains(methods, methodOllamaLocal) {\n\t\tvalues[\"OLLAMA_API_BASE\"] = strings.TrimSpace(ollamaAPIBase)\n\t\tvalues[\"OLLAMA_MODEL\"] = strings.TrimSpace(ollamaModel)\n\t}\n\tif contains(methods, methodOllamaCloud) {\n\t\tvalues[\"OLLAMA_CLOUD_API_BASE\"] = strings.TrimSpace(ollamaCloudAPIBase)\n\t\tvalues[\"OLLAMA_CLOUD_API_KEY\"] = strings.TrimSpace(ollamaCloudAPIKey)\n\t\tvalues[\"OLLAMA_CLOUD_MODEL\"] = strings.TrimSpace(ollamaCloudModel)\n\t}\n\tif contains(methods, methodBedrockAPI) {\n\t\tvalues[\"AWS_ACCESS_KEY_ID\"] = strings.TrimSpace(awsAccessKeyID)\n\t\tvalues[\"AWS_SECRET_ACCESS_KEY\"] = strings.TrimSpace(awsSecretAccessKey)\n\t\t// LiteLLM's Bedrock provider reads AWS_REGION_NAME, not the\n\t\t// boto3-standard AWS_REGION. Set both for SDK compatibility.\n\t\tvalues[\"AWS_REGION_NAME\"] = strings.TrimSpace(awsRegion)\n\t\tvalues[\"AWS_REGION\"] = strings.TrimSpace(awsRegion)\n\t}\n\tif contains(methods, methodVertexAPI) {\n\t\tvalues[\"GOOGLE_APPLICATION_CREDENTIALS\"] = strings.TrimSpace(vertexCredsPath)\n\t\tvalues[\"VERTEXAI_PROJECT\"] = strings.TrimSpace(vertexProject)\n\t\tvalues[\"VERTEXAI_LOCATION\"] = strings.TrimSpace(vertexLocation)\n\t}\n\tif contains(methods, methodAzureAPI) {\n\t\tvalues[\"AZURE_API_KEY\"] = strings.TrimSpace(azureAPIKey)\n\t\tvalues[\"AZURE_API_BASE\"] = strings.TrimSpace(azureAPIBase)\n\t\tvalues[\"AZURE_API_VERSION\"] = strings.TrimSpace(azureAPIVersion)\n\t}\n\tif githubToken != \"\" {\n\t\t// LiteLLM's github/ provider reads GITHUB_API_KEY; the GitHub\n\t\t// CLI + most docs use GITHUB_TOKEN. Set both so either path\n\t\t// works without operator surprise.\n\t\tvalues[\"GITHUB_TOKEN\"] = strings.TrimSpace(githubToken)\n\t\tvalues[\"GITHUB_API_KEY\"] = strings.TrimSpace(githubToken)\n\t}\n\tif groqKey != \"\" {\n\t\tvalues[\"GROQ_API_KEY\"] = strings.TrimSpace(groqKey)\n\t}\n\tif togetherKey != \"\" {\n\t\tvalues[\"TOGETHER_API_KEY\"] = strings.TrimSpace(togetherKey)\n\t}\n\tif fireworksKey != \"\" {\n\t\tvalues[\"FIREWORKS_API_KEY\"] = strings.TrimSpace(fireworksKey)\n\t}\n\tif cohereKey != \"\" {\n\t\tvalues[\"COHERE_API_KEY\"] = strings.TrimSpace(cohereKey)\n\t}\n\tif moonshotKey != \"\" {\n\t\tvalues[\"MOONSHOT_API_KEY\"] = strings.TrimSpace(moonshotKey)\n\t}\n\tif zaiKey != \"\" {\n\t\tvalues[\"ZAI_API_KEY\"] = strings.TrimSpace(zaiKey)\n\t}\n\tif dashscopeKey != \"\" {\n\t\tvalues[\"DASHSCOPE_API_KEY\"] = strings.TrimSpace(dashscopeKey)\n\t}\n\tif contains(methods, methodLMStudioLocal) {\n\t\tvalues[\"LMSTUDIO_API_BASE\"] = strings.TrimSpace(lmStudioAPIBase)\n\t\tvalues[\"LMSTUDIO_MODEL\"] = strings.TrimSpace(lmStudioModel)\n\t\t// LM Studio accepts any string as the API key; set a sentinel\n\t\t// so LiteLLM's openai-compatible shim doesn't fail an empty\n\t\t// Authorization header.\n\t\tvalues[\"LMSTUDIO_API_KEY\"] = \"lm-studio\"\n\t}\n\tif contains(methods, methodCustomOpenAIAPI) {\n\t\tvalues[\"CUSTOM_OPENAI_API_BASE\"] = strings.TrimSpace(customOpenAIAPIBase)\n\t\tvalues[\"CUSTOM_OPENAI_API_KEY\"] = strings.TrimSpace(customOpenAIAPIKey)\n\t\tvalues[\"CUSTOM_OPENAI_MODEL\"] = strings.TrimSpace(customOpenAIModel)\n\t}\n\n\tif useLangSmith && langSmithKey != \"\" {\n\t\tvalues[\"LANGSMITH_TRACING\"] = \"true\"\n\t\tvalues[\"LANGSMITH_API_KEY\"] = langSmithKey\n\t\tvalues[\"LANGSMITH_PROJECT\"] = \"decepticon\"\n\t}\n\n\tif err := config.WriteEnvFromEmbed(config.EnvPath(), values); err != nil {\n\t\treturn fmt.Errorf(\"write .env: %w\", err)\n\t}\n\n\t// Summary\n\tfmt.Println()\n\tfmt.Println(ui.Green.Render(\" ✓ Configuration saved\"))\n\tfmt.Println()\n\tfmt.Println(ui.Dim.Render(\" ┌──────────────────────────────────┐\"))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Methods \") + ui.Dim.Render(strings.Join(priority, \", \")))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Profile \") + ui.Dim.Render(profile))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Language \") + ui.Dim.Render(language))\n\tif useLangSmith {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" LangSmith \") + ui.Green.Render(\"enabled\"))\n\t}\n\tfmt.Println(ui.Dim.Render(\" │\"))\n\tfmt.Println(ui.Dim.Render(\" │ \") + ui.Dim.Render(config.EnvPath()))\n\tfmt.Println(ui.Dim.Render(\" └──────────────────────────────────┘\"))\n\tfmt.Println()\n\tui.DimText(\" Run 'decepticon' to start the platform\")\n\treturn nil\n}\n\nfunc contains(haystack []string, needle string) bool {\n\treturn slices.Contains(haystack, needle)\n}\n\nfunc nonEmpty(s string) error {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn fmt.Errorf(\"value is required\")\n\t}\n\treturn nil\n}\n\nfunc boolStr(b bool) string {\n\tif b {\n\t\treturn \"true\"\n\t}\n\treturn \"false\"\n}\n\n// buildOllamaModelField returns the OLLAMA_MODEL form field that\n// matches the host probe outcome: strict Select when tool-capable\n// models are pulled, otherwise a remediation Note (different message\n// for the reachable-but-no-tools case vs unreachable Ollama).\nfunc buildOllamaModelField(probe ollamaProbeResult, selected *string) huh.Field {\n\tif len(probe.ToolCapableModels) > 0 {\n\t\toptions := make([]huh.Option[string], 0, len(probe.ToolCapableModels))\n\t\tfor _, m := range probe.ToolCapableModels {\n\t\t\toptions = append(options, huh.NewOption(m, m))\n\t\t}\n\t\tif !slices.Contains(probe.ToolCapableModels, *selected) {\n\t\t\t*selected = probe.ToolCapableModels[0]\n\t\t}\n\t\treturn huh.NewSelect[string]().\n\t\t\tTitle(\"OLLAMA_MODEL\").\n\t\t\tDescription(\"Tool-capable models found on your host. Decepticon\\nagents always emit tool calls — these are the only\\nmodels the wizard will accept.\").\n\t\t\tOptions(options...).\n\t\t\tValue(selected)\n\t}\n\n\tif probe.Reachable {\n\t\treturn huh.NewNote().\n\t\t\tTitle(\"OLLAMA_MODEL — no tool-capable models found\").\n\t\t\tDescription(\"Ollama is reachable but none of your pulled models\\nadvertise the 'tools' capability. Decepticon agents\\nalways emit tool calls, so a model without tool\\nsupport cannot power them.\\n\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n\t}\n\n\treturn huh.NewNote().\n\t\tTitle(\"OLLAMA_MODEL — Ollama not reachable\").\n\t\tDescription(\"Could not reach Ollama at \" + defaultOllamaAPIBase + \".\\n\\nMost likely Ollama isn't running or is bound to\\n127.0.0.1 only (which the Decepticon container can't\\nsee). Launch it on all interfaces, pull a tool-capable\\nmodel, then re-run the wizard:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n}\n\n// ollamaUnusableError surfaces the post-form remediation when the user\n// picked Ollama but the probe found nothing usable. Two flavors so the\n// hint matches whichever in-form Note was shown.\nfunc ollamaUnusableError(probe ollamaProbeResult, baseURL string) error {\n\tif probe.Reachable {\n\t\treturn fmt.Errorf(\n\t\t\t\"Ollama selected but no tool-capable models found on the host.\\n\" +\n\t\t\t\t\"Decepticon agents always emit tool calls — pull a tool-capable\\n\" +\n\t\t\t\t\"model and verify it advertises tools, then re-run:\\n\\n\" +\n\t\t\t\t\" ollama pull qwen3-coder:30b\\n\" +\n\t\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\" +\n\t\t\t\t\" decepticon onboard --reset\")\n\t}\n\treturn fmt.Errorf(\n\t\t\"Ollama selected but the host probe could not reach %s.\\n\"+\n\t\t\t\"Make sure Ollama is running and bound to all interfaces (the\\n\"+\n\t\t\t\"default 127.0.0.1 binding is invisible to containers), then\\n\"+\n\t\t\t\"pull a tool-capable model and re-run:\\n\\n\"+\n\t\t\t\" OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\"+\n\t\t\t\" ollama pull qwen3-coder:30b\\n\"+\n\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\"+\n\t\t\t\" decepticon onboard --reset\",\n\t\tbaseURL)\n}\n"}, "3c1da9e1610e649740c32220e2d58b1b5bbbfdfb": {"t": 1785755401.7017238, "n": 39506, "h": "f47aba7270d4855e", "c": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/huh/v2\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui\"\n\t\"github.com/spf13/cobra\"\n)\n\n// onboardOllamaProbeBudget bounds the wait for the background Ollama\n// discovery probe at form-construction time. Per-request timeout\n// (ollama_models.go) is much shorter; this is the worst-case across\n// all candidate URLs.\nconst onboardOllamaProbeBudget = 3 * time.Second\n\nvar resetFlag bool\n\nvar onboardCmd = &cobra.Command{\n\tUse: \"onboard\",\n\tShort: \"Configure Decepticon (auth methods, model profile, observability)\",\n\tRunE: runOnboard,\n}\n\nfunc init() {\n\tonboardCmd.Flags().BoolVar(&resetFlag, \"reset\", false, \"Reconfigure even if .env already exists\")\n\trootCmd.AddCommand(onboardCmd)\n}\n\n// AuthMethod identifiers — must match decepticon/llm/models.py::AuthMethod.\nconst (\n\tmethodAnthropicOAuth = \"anthropic_oauth\"\n\tmethodAnthropicAPI = \"anthropic_api\"\n\tmethodOpenAIOAuth = \"openai_oauth\"\n\tmethodOpenAIAPI = \"openai_api\"\n\tmethodGoogleOAuth = \"google_oauth\"\n\tmethodGoogleAPI = \"google_api\"\n\tmethodMiniMaxAPI = \"minimax_api\"\n\tmethodDeepSeekAPI = \"deepseek_api\"\n\tmethodXAIAPI = \"xai_api\"\n\tmethodGrokOAuth = \"grok_oauth\"\n\tmethodMistralAPI = \"mistral_api\"\n\tmethodOpenRouterAPI = \"openrouter_api\"\n\tmethodNvidiaAPI = \"nvidia_api\"\n\tmethodCopilotOAuth = \"copilot_oauth\"\n\tmethodPerplexityOAuth = \"perplexity_oauth\"\n\tmethodOllamaLocal = \"ollama_local\"\n\tmethodOllamaCloud = \"ollama_cloud\"\n\t// Cloud gateways added in the OpenClaude provider migration.\n\tmethodBedrockAPI = \"bedrock_api\"\n\tmethodVertexAPI = \"vertex_api\"\n\tmethodAzureAPI = \"azure_api\"\n\tmethodGroqAPI = \"groq_api\"\n\tmethodTogetherAPI = \"together_api\"\n\tmethodFireworksAPI = \"fireworks_api\"\n\tmethodCohereAPI = \"cohere_api\"\n\tmethodMoonshotAPI = \"moonshot_api\"\n\tmethodZaiAPI = \"zai_api\"\n\tmethodDashscopeAPI = \"dashscope_api\"\n\tmethodGitHubModelsAPI = \"github_models_api\"\n\tmethodLMStudioLocal = \"lmstudio_local\"\n\tmethodCustomOpenAIAPI = \"custom_openai_api\"\n)\n\n// Defaults shown in form placeholders for the new providers.\nconst (\n\tdefaultLMStudioAPIBase = \"http://host.docker.internal:1234/v1\"\n\tdefaultLMStudioModel = \"qwen2.5-coder-7b-instruct\"\n\tdefaultAzureAPIVersion = \"2024-08-01-preview\"\n)\n\n// Default Ollama wiring shown to OSS users. `host.docker.internal`\n// is the universal answer regardless of where Ollama runs (macOS host,\n// WSL2 distro, native Linux): from inside Decepticon's containers it\n// resolves to the host network namespace via the\n// `extra_hosts: [host.docker.internal:host-gateway]` entry on the\n// litellm service in docker-compose.yml. `localhost` is never the\n// right answer here — that's the container itself.\n//\n// The host's Ollama must additionally be bound to 0.0.0.0 (the default\n// 127.0.0.1 binding only accepts host-side connections); the wizard\n// surfaces this requirement to the user.\n//\n// The default model is the smallest/fastest one most laptops can\n// actually run; users with a GPU will pick something like qwen3-coder:30b.\nconst (\n\tdefaultOllamaAPIBase = \"http://host.docker.internal:11434\"\n\tdefaultOllamaModel = \"llama3.2\"\n)\n\n// methodOrder is the priority order surfaced in the wizard. The\n// resulting DECEPTICON_AUTH_PRIORITY preserves this order, filtered\n// to the methods the user actually selected. OAuth precedes the\n// matching API on purpose: a subscription primary should fall back\n// to the paid API only when the subscription quota is exhausted.\n// Ollama sits last: cloud providers are usually preferred when both\n// are configured, but a user wanting to lead with local-only inference\n// can reorder the priority manually in .env.\nvar methodOrder = []string{\n\tmethodAnthropicOAuth,\n\tmethodAnthropicAPI,\n\tmethodOpenAIOAuth,\n\tmethodOpenAIAPI,\n\tmethodGoogleOAuth,\n\tmethodGoogleAPI,\n\tmethodMiniMaxAPI,\n\tmethodDeepSeekAPI,\n\tmethodXAIAPI,\n\tmethodGrokOAuth,\n\tmethodMistralAPI,\n\tmethodOpenRouterAPI,\n\tmethodNvidiaAPI,\n\tmethodCopilotOAuth,\n\tmethodPerplexityOAuth,\n\t// Cloud gateways — anthropic-via-cloud paths first, then\n\t// other multi-vendor hubs.\n\tmethodBedrockAPI,\n\tmethodVertexAPI,\n\tmethodAzureAPI,\n\tmethodGitHubModelsAPI,\n\tmethodGroqAPI,\n\tmethodTogetherAPI,\n\tmethodFireworksAPI,\n\tmethodCohereAPI,\n\tmethodMoonshotAPI,\n\tmethodZaiAPI,\n\tmethodDashscopeAPI,\n\t// Local last so cloud-preferred default still picks remote\n\t// providers as primary; users can re-order in .env.\n\tmethodLMStudioLocal,\n\tmethodOllamaLocal,\n\tmethodOllamaCloud,\n\tmethodCustomOpenAIAPI,\n}\n\nfunc runOnboard(cmd *cobra.Command, args []string) error {\n\tif config.EnvExists() && !resetFlag {\n\t\tui.Info(\".env already configured at \" + config.EnvPath())\n\t\tui.DimText(\"Run 'decepticon onboard --reset' to reconfigure\")\n\t\treturn nil\n\t}\n\n\t// Kick off Ollama discovery in the background so the network\n\t// round-trip overlaps with huh's startup work; the OLLAMA_MODEL\n\t// field type depends on the result (Select vs remediation Note).\n\tprobeCh := make(chan ollamaProbeResult, 1)\n\tgo func() {\n\t\tprobeCh <- probeOllamaForOnboard(defaultOllamaAPIBase)\n\t}()\n\n\tvar (\n\t\tmethods []string\n\t\tanthropicKey string\n\t\topenaiKey string\n\t\tgeminiKey string\n\t\tminimaxKey string\n\t\tdeepseekKey string\n\t\txaiKey string\n\t\tmistralKey string\n\t\topenrouterKey string\n\t\tnvidiaKey string\n\t\tgeminiSessionCookies string\n\t\tcopilotRefreshToken string\n\t\tgrokSessionToken string\n\t\tperplexitySessionToken string\n\t\tollamaAPIBase = defaultOllamaAPIBase\n\t\tollamaModel = defaultOllamaModel\n\t\t// Cloud gateways\n\t\tawsAccessKeyID string\n\t\tawsSecretAccessKey string\n\t\tawsRegion = \"us-east-1\"\n\t\tvertexCredsPath string\n\t\tvertexProject string\n\t\tvertexLocation = \"us-central1\"\n\t\tazureAPIKey string\n\t\tazureAPIBase string\n\t\tazureAPIVersion = defaultAzureAPIVersion\n\t\tgroqKey string\n\t\ttogetherKey string\n\t\tfireworksKey string\n\t\tcohereKey string\n\t\tmoonshotKey string\n\t\tzaiKey string\n\t\tdashscopeKey string\n\t\tgithubToken string\n\t\t// Cloud Ollama\n\t\tollamaCloudAPIBase string\n\t\tollamaCloudAPIKey string\n\t\tollamaCloudModel string\n\t\t// LM Studio (local OpenAI-compatible)\n\t\tlmStudioAPIBase = defaultLMStudioAPIBase\n\t\tlmStudioModel = defaultLMStudioModel\n\t\t// Custom OpenAI-compatible endpoint\n\t\tcustomOpenAIAPIBase string\n\t\tcustomOpenAIAPIKey string\n\t\tcustomOpenAIModel string\n\t\tprofile string\n\t\tlanguage = \"en\"\n\t\tuseLangSmith bool\n\t\tlangSmithKey string\n\t)\n\t// Block on the probe (zero-value result on timeout means\n\t// \"unreachable\" — drops through to the remediation Note).\n\t// time.NewTimer + Stop avoids the time.After timer leak when the\n\t// probe finishes first.\n\tprobeTimer := time.NewTimer(onboardOllamaProbeBudget)\n\tvar ollamaProbe ollamaProbeResult\n\tselect {\n\tcase ollamaProbe = <-probeCh:\n\t\tprobeTimer.Stop()\n\tcase <-probeTimer.C:\n\t}\n\tollamaModelField := buildOllamaModelField(ollamaProbe, &ollamaModel)\n\n\tform := huh.NewForm(\n\t\t// Intro\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Decepticon Setup\").\n\t\t\t\tDescription(\"Configure auth methods, model profile, and\\nobservability.\\n\\nUse ↑↓ to navigate, space to toggle, Enter to confirm.\"),\n\t\t),\n\n\t\t// Step 1: Auth methods (multi-select)\n\t\thuh.NewGroup(\n\t\t\thuh.NewMultiSelect[string]().\n\t\t\t\tTitle(\"Auth Methods\").\n\t\t\t\tDescription(\"Press SPACE to toggle, ENTER to confirm.\\nUse ↑↓ to scroll, '/' to filter the list.\\nPick every credential you have — each is an\\nindependent fallback in priority order.\").\n\t\t\t\tFilterable(true).\n\t\t\t\tHeight(15).\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"Claude Code OAuth — Anthropic subscription (auth/*)\", methodAnthropicOAuth),\n\t\t\t\t\thuh.NewOption(\"Anthropic API Key — sk-ant-...\", methodAnthropicAPI),\n\t\t\t\t\thuh.NewOption(\"ChatGPT OAuth — ChatGPT Pro/Plus/Team subscription (auth/gpt-*)\", methodOpenAIOAuth),\n\t\t\t\t\thuh.NewOption(\"OpenAI API Key — sk-...\", methodOpenAIAPI),\n\t\t\t\t\thuh.NewOption(\"Google API Key — AIza... (Gemini)\", methodGoogleAPI),\n\t\t\t\t\thuh.NewOption(\"MiniMax API Key — eyJ...\", methodMiniMaxAPI),\n\t\t\t\t\thuh.NewOption(\"DeepSeek API Key — sk-...\", methodDeepSeekAPI),\n\t\t\t\t\thuh.NewOption(\"xAI API Key — xai-... (Grok)\", methodXAIAPI),\n\t\t\t\t\thuh.NewOption(\"Mistral API Key — (no fixed prefix)\", methodMistralAPI),\n\t\t\t\t\thuh.NewOption(\"OpenRouter API Key — sk-or-...\", methodOpenRouterAPI),\n\t\t\t\t\thuh.NewOption(\"Nvidia NIM API Key — nvapi-...\", methodNvidiaAPI),\n\t\t\t\t\thuh.NewOption(\"Gemini Advanced — Google One AI Premium subscription (gemini-sub/*)\", methodGoogleOAuth),\n\t\t\t\t\thuh.NewOption(\"SuperGrok — X Premium+ Grok subscription (grok-sub/*)\", methodGrokOAuth),\n\t\t\t\t\thuh.NewOption(\"GitHub Copilot Pro — Copilot subscription (copilot/*)\", methodCopilotOAuth),\n\t\t\t\t\thuh.NewOption(\"Perplexity Pro — Perplexity subscription (pplx-sub/*)\", methodPerplexityOAuth),\n\t\t\t\t\thuh.NewOption(\"AWS Bedrock — Anthropic models on AWS (bedrock/*)\", methodBedrockAPI),\n\t\t\t\t\thuh.NewOption(\"GCP Vertex AI — Anthropic + Gemini on GCP (vertex_ai/*)\", methodVertexAPI),\n\t\t\t\t\thuh.NewOption(\"Azure OpenAI — Azure-hosted GPT deployments (azure/*)\", methodAzureAPI),\n\t\t\t\t\thuh.NewOption(\"GitHub Models — GPT family via GitHub PAT (github/*)\", methodGitHubModelsAPI),\n\t\t\t\t\thuh.NewOption(\"Groq Cloud — LPU-accelerated Llama (groq/*)\", methodGroqAPI),\n\t\t\t\t\thuh.NewOption(\"Together AI — Llama / Mixtral hub (together_ai/*)\", methodTogetherAPI),\n\t\t\t\t\thuh.NewOption(\"Fireworks AI — Llama / Mixtral hub (fireworks_ai/*)\", methodFireworksAPI),\n\t\t\t\t\thuh.NewOption(\"Cohere Command — Command-A / Command-R (cohere/*)\", methodCohereAPI),\n\t\t\t\t\thuh.NewOption(\"Moonshot Kimi K2 — Kimi K2 (moonshot/*)\", methodMoonshotAPI),\n\t\t\t\t\thuh.NewOption(\"Z.ai GLM-4.5 — GLM-4.5 / GLM-4.5-Air (zai/*)\", methodZaiAPI),\n\t\t\t\t\thuh.NewOption(\"Alibaba DashScope — Qwen Max/Plus/Turbo (dashscope/*)\", methodDashscopeAPI),\n\t\t\t\t\thuh.NewOption(\"LM Studio (local) — local OpenAI-compatible server (lm_studio/*)\", methodLMStudioLocal),\n\t\t\t\t\thuh.NewOption(\"Local LLM (Ollama) — any pulled model, no API key\", methodOllamaLocal),\n\t\t\t\t\thuh.NewOption(\"Ollama Cloud — hosted Ollama (ollama_chat/* via cloud)\", methodOllamaCloud),\n\t\t\t\t\thuh.NewOption(\"Custom OpenAI Endpoint — bring-your-own OpenAI-compatible URL\", methodCustomOpenAIAPI),\n\t\t\t\t).\n\t\t\t\tValue(&methods).\n\t\t\t\tValidate(func(s []string) error {\n\t\t\t\t\tif len(s) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"select at least one credential\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"1 / 5 · Credentials\").\n\t\t\tDescription(\"Select all that apply\"),\n\n\t\t// Step 2a: Anthropic API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Anthropic API Key\").\n\t\t\t\tPlaceholder(\"sk-ant-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&anthropicKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Anthropic API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicAPI) }),\n\n\t\t// Step 2c: OpenAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenAI API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenAI API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIAPI) }),\n\n\t\t// Step 2c: Google API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Google (Gemini) API Key\").\n\t\t\t\tPlaceholder(\"AIza...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Google API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleAPI) }),\n\n\t\t// Step 2d: MiniMax API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MiniMax API Key\").\n\t\t\t\tPlaceholder(\"eyJ...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&minimaxKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · MiniMax API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMiniMaxAPI) }),\n\n\t\t// Step 2d-i: DeepSeek API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DeepSeek API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&deepseekKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DeepSeek API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDeepSeekAPI) }),\n\n\t\t// Step 2d-ii: xAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"xAI API Key\").\n\t\t\t\tPlaceholder(\"xai-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&xaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · xAI API (Grok)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodXAIAPI) }),\n\n\t\t// Step 2d-iii: Mistral API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Mistral API Key\").\n\t\t\t\tPlaceholder(\"paste your Mistral API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&mistralKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Mistral API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMistralAPI) }),\n\n\t\t// Step 2e: OpenRouter API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenRouter API Key\").\n\t\t\t\tPlaceholder(\"sk-or-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openrouterKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenRouter API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenRouterAPI) }),\n\n\t\t// Step 2f: Nvidia NIM API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Nvidia NIM API Key\").\n\t\t\t\tPlaceholder(\"nvapi-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&nvidiaKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Nvidia NIM API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodNvidiaAPI) }),\n\n\t\t// Step 2-oauth-i: Gemini Advanced subscription\n\t\t// Multi-cookie value (NID + Secure-1PSID + Secure-1PSIDTS, etc.)\n\t\t// joined with semicolons. Optional — power users can drop a\n\t\t// tokens.json under ~/.config/gemini/ instead and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Gemini Advanced Subscription\").\n\t\t\t\tDescription(\"Open gemini.google.com → DevTools → Application →\\nCookies → gemini.google.com. Copy the values of\\nNID, __Secure-1PSID, __Secure-1PSIDTS as a single\\nsemicolon-joined string. Or leave blank to use\\nGEMINI_ACCESS_TOKEN / ~/.config/gemini/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GEMINI_SESSION_COOKIES\").\n\t\t\t\tPlaceholder(\"NID=...; __Secure-1PSID=... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiSessionCookies),\n\t\t).Title(\"2 / 5 · Gemini Advanced\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleOAuth) }),\n\n\t\t// Step 2-oauth-ii: SuperGrok subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"SuperGrok Subscription\").\n\t\t\t\tDescription(\"Open grok.com → DevTools → Application →\\nCookies → grok.com → copy the value of\\n`auth_token`. Or leave blank to use\\nGROK_ACCESS_TOKEN / ~/.config/grok/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROK_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste auth_token cookie value (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&grokSessionToken),\n\t\t).Title(\"2 / 5 · SuperGrok\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGrokOAuth) }),\n\n\t\t// Step 2-oauth-iii: GitHub Copilot Pro subscription\n\t\t// Refresh token rotation, not a single session cookie. Devs can\n\t\t// extract one from VSCode's Copilot extension config or follow\n\t\t// gh-copilot-cli onboarding. Optional via tokens.json fallback.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GitHub Copilot Pro Subscription\").\n\t\t\t\tDescription(\"Provide a Copilot refresh token (long-lived, used\\nto rotate access tokens). Or leave blank to use\\nCOPILOT_ACCESS_TOKEN / ~/.config/copilot/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COPILOT_REFRESH_TOKEN\").\n\t\t\t\tPlaceholder(\"ghu_... or ghr_... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&copilotRefreshToken),\n\t\t).Title(\"2 / 5 · Copilot Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCopilotOAuth) }),\n\n\t\t// Step 2-oauth-iv: Perplexity Pro subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Perplexity Pro Subscription\").\n\t\t\t\tDescription(\"Open perplexity.ai → DevTools → Application →\\nCookies → perplexity.ai → copy the value of\\n`next-auth.session-token`. Or leave blank to use\\nPERPLEXITY_ACCESS_TOKEN / ~/.config/perplexity/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"PERPLEXITY_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste next-auth.session-token (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&perplexitySessionToken),\n\t\t).Title(\"2 / 5 · Perplexity Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodPerplexityOAuth) }),\n\n\t\t// Step 2g: Local Ollama. The OLLAMA_MODEL field is built from\n\t\t// the host probe (buildOllamaModelField): a strict Select when\n\t\t// tool-capable models are pulled, a remediation Note otherwise.\n\t\t// The post-form gate refuses to write .env in the Note cases.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Local Ollama\").\n\t\t\t\tDescription(\"Ollama must already be running on your host with\\nat least one tool-capable model pulled. Launch it\\nbound to all interfaces so the Decepticon container\\ncan reach it:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\\nThe default 127.0.0.1 binding only accepts host-side\\nconnections — containers won't see it.\\n\\nThe default URL `host.docker.internal:11434` works\\non macOS, Linux, and WSL2 (with or without Docker\\nDesktop). Only change it for remote / custom setups.\\nNote: the model list is probed against the default URL\\nat wizard start; if you customize OLLAMA_API_BASE,\\nfinish the wizard then re-run 'decepticon onboard\\n--reset' so the probe targets the new endpoint.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_API_BASE\").\n\t\t\t\tPlaceholder(defaultOllamaAPIBase).\n\t\t\t\tValue(&ollamaAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\tollamaModelField,\n\t\t).Title(\"2 / 5 · Local LLM (Ollama)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaLocal) }),\n\n\t\t// Step 2-cloud-i: AWS Bedrock — three-field group (key + secret + region)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"AWS Bedrock\").\n\t\t\t\tDescription(\"Bedrock uses AWS SigV4 — provide IAM access key,\\nsecret, and the region where Anthropic models are\\nenabled (us-east-1 or us-west-2 typically).\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_ACCESS_KEY_ID\").\n\t\t\t\tPlaceholder(\"AKIA...\").\n\t\t\t\tValue(&awsAccessKeyID).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_SECRET_ACCESS_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&awsSecretAccessKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AWS_REGION\").\n\t\t\t\tPlaceholder(\"us-east-1\").\n\t\t\t\tValue(&awsRegion).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · AWS Bedrock\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodBedrockAPI) }),\n\n\t\t// Step 2-cloud-ii: GCP Vertex AI — service-account JSON path + project + region\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GCP Vertex AI\").\n\t\t\t\tDescription(\"Vertex AI uses a Google Cloud service-account JSON.\\nDownload one from IAM & Admin → Service Accounts and\\npaste the absolute file path. Project + region must\\nmatch where you enabled Anthropic + Gemini models.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GOOGLE_APPLICATION_CREDENTIALS\").\n\t\t\t\tPlaceholder(\"/abs/path/to/service-account.json\").\n\t\t\t\tValue(&vertexCredsPath).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"VERTEXAI_PROJECT\").\n\t\t\t\tPlaceholder(\"my-gcp-project-id\").\n\t\t\t\tValue(&vertexProject).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"VERTEXAI_LOCATION\").\n\t\t\t\tPlaceholder(\"us-central1\").\n\t\t\t\tValue(&vertexLocation).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · GCP Vertex AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodVertexAPI) }),\n\n\t\t// Step 2-cloud-iii: Azure OpenAI — key + endpoint + version\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Azure OpenAI Service\").\n\t\t\t\tDescription(\"Endpoint is your azure-openai resource URL,\\ne.g. https://my-resource.openai.azure.com.\\nThe deployment names map 1:1 to gpt-5.5/gpt-5.4/...\\nso the model matrix in DF resolves out of the box.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&azureAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_BASE\").\n\t\t\t\tPlaceholder(\"https://<resource>.openai.azure.com\").\n\t\t\t\tValue(&azureAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"AZURE_API_VERSION\").\n\t\t\t\tPlaceholder(defaultAzureAPIVersion).\n\t\t\t\tValue(&azureAPIVersion).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Azure OpenAI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAzureAPI) }),\n\n\t\t// Step 2-cloud-iv: GitHub Models — PAT\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GITHUB_TOKEN\").\n\t\t\t\tDescription(\"Fine-grained PAT with the 'models' permission scope.\\nGenerate at github.com/settings/personal-access-tokens.\").\n\t\t\t\tPlaceholder(\"github_pat_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&githubToken).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · GitHub Models\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGitHubModelsAPI) }),\n\n\t\t// Step 2-cloud-v: Groq\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROQ_API_KEY\").\n\t\t\t\tPlaceholder(\"gsk_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&groqKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Groq Cloud\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGroqAPI) }),\n\n\t\t// Step 2-cloud-vi: Together AI\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"TOGETHER_API_KEY\").\n\t\t\t\tPlaceholder(\"paste your Together AI API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&togetherKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Together AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodTogetherAPI) }),\n\n\t\t// Step 2-cloud-vii: Fireworks AI\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"FIREWORKS_API_KEY\").\n\t\t\t\tPlaceholder(\"fw_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&fireworksKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Fireworks AI\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodFireworksAPI) }),\n\n\t\t// Step 2-cloud-viii: Cohere\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COHERE_API_KEY\").\n\t\t\t\tPlaceholder(\"paste your Cohere API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&cohereKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Cohere\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCohereAPI) }),\n\n\t\t// Step 2-cloud-ix: Moonshot Kimi K2\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MOONSHOT_API_KEY\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&moonshotKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Moonshot Kimi K2\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMoonshotAPI) }),\n\n\t\t// Step 2-cloud-x: Z.ai GLM-4.5\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"ZAI_API_KEY\").\n\t\t\t\tDescription(\"Z.ai is OpenAI-compatible — base URL is hard-wired to\\nhttps://api.z.ai/api/paas/v4. Get a key at z.ai/manage.\").\n\t\t\t\tPlaceholder(\"paste your Z.ai API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&zaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Z.ai GLM-4.5\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodZaiAPI) }),\n\n\t\t// Step 2-cloud-xi: Alibaba DashScope (Qwen)\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DASHSCOPE_API_KEY\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&dashscopeKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DashScope (Qwen)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDashscopeAPI) }),\n\n\t\t// Step 2-local-ii: LM Studio (local OpenAI-compatible)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"LM Studio\").\n\t\t\t\tDescription(\"LM Studio runs a local OpenAI-compatible server.\\nLaunch the LM Studio app and start the server\\n(Developer tab → Start). The default port is 1234.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LMSTUDIO_API_BASE\").\n\t\t\t\tPlaceholder(defaultLMStudioAPIBase).\n\t\t\t\tValue(&lmStudioAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LMSTUDIO_MODEL\").\n\t\t\t\tDescription(\"Model identifier as shown in LM Studio (e.g.\\nqwen2.5-coder-7b-instruct).\").\n\t\t\t\tPlaceholder(defaultLMStudioModel).\n\t\t\t\tValue(&lmStudioModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · LM Studio\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodLMStudioLocal) }),\n\n\t\t// Step 2-local-iii: Ollama Cloud (hosted)\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Ollama Cloud\").\n\t\t\t\tDescription(\"Hosted Ollama — same /api/chat tool-calling endpoint\\nas local Ollama, just behind an API key.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_API_BASE\").\n\t\t\t\tPlaceholder(\"https://api.ollama.com/v1\").\n\t\t\t\tValue(&ollamaCloudAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&ollamaCloudAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_CLOUD_MODEL\").\n\t\t\t\tPlaceholder(\"llama3.3:70b\").\n\t\t\t\tValue(&ollamaCloudModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Ollama Cloud\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaCloud) }),\n\n\t\t// Step 2-custom: Custom OpenAI-compatible endpoint\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Custom OpenAI-compatible Endpoint\").\n\t\t\t\tDescription(\"Point at any OpenAI-compatible server (gateways,\\nself-hosted vLLM, internal LiteLLM, etc.). The model\\nname is sent verbatim to the upstream — match what\\nyour gateway exposes.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_API_BASE\").\n\t\t\t\tPlaceholder(\"https://gateway.example.com/v1\").\n\t\t\t\tValue(&customOpenAIAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_API_KEY\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&customOpenAIAPIKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CUSTOM_OPENAI_MODEL\").\n\t\t\t\tPlaceholder(\"gpt-4o-mini\").\n\t\t\t\tValue(&customOpenAIModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Custom OpenAI Endpoint\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCustomOpenAIAPI) }),\n\n\t\t// Step 3: Model profile\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Model Profile\").\n\t\t\t\tDescription(\"eco per-agent tier (recommended)\\nmax every agent on HIGH (expensive)\\ntest every agent on LOW (development)\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"eco — per-agent tier (recommended)\", \"eco\"),\n\t\t\t\t\thuh.NewOption(\"max — every agent on HIGH (expensive)\", \"max\"),\n\t\t\t\t\thuh.NewOption(\"test — every agent on LOW (development)\", \"test\"),\n\t\t\t\t).\n\t\t\t\tValue(&profile),\n\t\t).Title(\"3 / 5 · Profile\"),\n\n\t\t// Step 4: Language\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Agent Language\").\n\t\t\t\tDescription(\"Language for all agent prose output (menus, questions,\\nsummaries, errors). Technical output stays in English.\\nCountry codes (dk, se, jp, cn) are auto-resolved.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"en — English (default)\", \"en\"),\n\t\t\t\t\thuh.NewOption(\"no — Norwegian\", \"no\"),\n\t\t\t\t\thuh.NewOption(\"da — Danish\", \"da\"),\n\t\t\t\t\thuh.NewOption(\"sv — Swedish\", \"sv\"),\n\t\t\t\t\thuh.NewOption(\"fi — Finnish\", \"fi\"),\n\t\t\t\t\thuh.NewOption(\"is — Icelandic\", \"is\"),\n\t\t\t\t\thuh.NewOption(\"ko — Korean\", \"ko\"),\n\t\t\t\t\thuh.NewOption(\"ja — Japanese\", \"ja\"),\n\t\t\t\t\thuh.NewOption(\"zh — Chinese\", \"zh\"),\n\t\t\t\t\thuh.NewOption(\"zh-tw — Traditional Chinese\", \"zh-tw\"),\n\t\t\t\t\thuh.NewOption(\"es — Spanish\", \"es\"),\n\t\t\t\t\thuh.NewOption(\"pt — Portuguese\", \"pt\"),\n\t\t\t\t\thuh.NewOption(\"pt-br — Brazilian Portuguese\", \"pt-br\"),\n\t\t\t\t\thuh.NewOption(\"de — German\", \"de\"),\n\t\t\t\t\thuh.NewOption(\"fr — French\", \"fr\"),\n\t\t\t\t\thuh.NewOption(\"nl — Dutch\", \"nl\"),\n\t\t\t\t\thuh.NewOption(\"it — Italian\", \"it\"),\n\t\t\t\t\thuh.NewOption(\"pl — Polish\", \"pl\"),\n\t\t\t\t\thuh.NewOption(\"cs — Czech\", \"cs\"),\n\t\t\t\t\thuh.NewOption(\"uk — Ukrainian\", \"uk\"),\n\t\t\t\t\thuh.NewOption(\"ro — Romanian\", \"ro\"),\n\t\t\t\t\thuh.NewOption(\"hr — Croatian\", \"hr\"),\n\t\t\t\t\thuh.NewOption(\"bg — Bulgarian\", \"bg\"),\n\t\t\t\t\thuh.NewOption(\"ru — Russian\", \"ru\"),\n\t\t\t\t\thuh.NewOption(\"el — Greek\", \"el\"),\n\t\t\t\t\thuh.NewOption(\"hu — Hungarian\", \"hu\"),\n\t\t\t\t\thuh.NewOption(\"tr — Turkish\", \"tr\"),\n\t\t\t\t\thuh.NewOption(\"ar — Arabic\", \"ar\"),\n\t\t\t\t\thuh.NewOption(\"fa — Persian\", \"fa\"),\n\t\t\t\t\thuh.NewOption(\"he — Hebrew\", \"he\"),\n\t\t\t\t\thuh.NewOption(\"hi — Hindi\", \"hi\"),\n\t\t\t\t\thuh.NewOption(\"th — Thai\", \"th\"),\n\t\t\t\t\thuh.NewOption(\"vi — Vietnamese\", \"vi\"),\n\t\t\t\t\thuh.NewOption(\"id — Indonesian\", \"id\"),\n\t\t\t\t\thuh.NewOption(\"ms — Malay\", \"ms\"),\n\t\t\t\t\thuh.NewOption(\"tl — Filipino\", \"tl\"),\n\t\t\t\t\thuh.NewOption(\"sw — Swahili\", \"sw\"),\n\t\t\t\t\thuh.NewOption(\"af — Afrikaans\", \"af\"),\n\t\t\t\t\thuh.NewOption(\"wenyan — 文言文 + English technical terms\", \"wenyan\"),\n\t\t\t\t).\n\t\t\t\tValue(&language),\n\t\t).Title(\"4 / 5 · Language\"),\n\n\t\t// Step 5a: LangSmith toggle\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Enable LangSmith?\").\n\t\t\t\tDescription(\"LLM observability and trace collection\").\n\t\t\t\tAffirmative(\"Yes\").\n\t\t\t\tNegative(\"No\").\n\t\t\t\tValue(&useLangSmith),\n\t\t).Title(\"5 / 5 · Observability\"),\n\n\t\t// Step 5b: LangSmith key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LangSmith API Key\").\n\t\t\t\tPlaceholder(\"lsv2_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&langSmithKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"5 / 5 · LangSmith\").\n\t\t\tWithHideFunc(func() bool { return !useLangSmith }),\n\t).WithTheme(huh.ThemeFunc(ui.DecepticonTheme))\n\n\tif err := form.Run(); err != nil {\n\t\treturn fmt.Errorf(\"setup cancelled: %w\", err)\n\t}\n\n\t// Strict-mode gate: refuse to write .env when the user picked\n\t// Ollama but the host probe found no tool-capable model. The\n\t// in-form Note shows the same remediation; this is the boundary\n\t// guarantee that a broken setup never ships.\n\tif contains(methods, methodOllamaLocal) && len(ollamaProbe.ToolCapableModels) == 0 {\n\t\treturn ollamaUnusableError(ollamaProbe, ollamaAPIBase)\n\t}\n\n\t// huh.MultiSelect returns selected values in option order, not the\n\t// order the user toggled. Re-derive the priority by walking\n\t// methodOrder and keeping only what the user picked.\n\tpriority := make([]string, 0, len(methods))\n\tfor _, m := range methodOrder {\n\t\tif contains(methods, m) {\n\t\t\tpriority = append(priority, m)\n\t\t}\n\t}\n\n\tvalues := map[string]string{\n\t\t\"DECEPTICON_MODEL_PROFILE\": profile,\n\t\t\"DECEPTICON_LANGUAGE\": language,\n\t\t\"DECEPTICON_AUTH_PRIORITY\": strings.Join(priority, \",\"),\n\t\t\"DECEPTICON_AUTH_CLAUDE_CODE\": boolStr(contains(methods, methodAnthropicOAuth)),\n\t\t\"DECEPTICON_AUTH_CHATGPT\": boolStr(contains(methods, methodOpenAIOAuth)),\n\t\t\"DECEPTICON_AUTH_GEMINI\": boolStr(contains(methods, methodGoogleOAuth)),\n\t\t\"DECEPTICON_AUTH_GROK\": boolStr(contains(methods, methodGrokOAuth)),\n\t\t\"DECEPTICON_AUTH_COPILOT\": boolStr(contains(methods, methodCopilotOAuth)),\n\t\t\"DECEPTICON_AUTH_PERPLEXITY\": boolStr(contains(methods, methodPerplexityOAuth)),\n\t}\n\n\tif anthropicKey != \"\" {\n\t\tvalues[\"ANTHROPIC_API_KEY\"] = anthropicKey\n\t}\n\tif openaiKey != \"\" {\n\t\tvalues[\"OPENAI_API_KEY\"] = openaiKey\n\t}\n\tif geminiKey != \"\" {\n\t\tvalues[\"GEMINI_API_KEY\"] = geminiKey\n\t}\n\tif minimaxKey != \"\" {\n\t\tvalues[\"MINIMAX_API_KEY\"] = minimaxKey\n\t}\n\tif deepseekKey != \"\" {\n\t\tvalues[\"DEEPSEEK_API_KEY\"] = deepseekKey\n\t}\n\tif xaiKey != \"\" {\n\t\tvalues[\"XAI_API_KEY\"] = xaiKey\n\t}\n\tif mistralKey != \"\" {\n\t\tvalues[\"MISTRAL_API_KEY\"] = mistralKey\n\t}\n\tif openrouterKey != \"\" {\n\t\tvalues[\"OPENROUTER_API_KEY\"] = openrouterKey\n\t}\n\tif nvidiaKey != \"\" {\n\t\tvalues[\"NVIDIA_API_KEY\"] = nvidiaKey\n\t}\n\tif geminiSessionCookies != \"\" {\n\t\tvalues[\"GEMINI_SESSION_COOKIES\"] = geminiSessionCookies\n\t}\n\tif grokSessionToken != \"\" {\n\t\tvalues[\"GROK_SESSION_TOKEN\"] = grokSessionToken\n\t}\n\tif copilotRefreshToken != \"\" {\n\t\tvalues[\"COPILOT_REFRESH_TOKEN\"] = copilotRefreshToken\n\t}\n\tif perplexitySessionToken != \"\" {\n\t\tvalues[\"PERPLEXITY_SESSION_TOKEN\"] = perplexitySessionToken\n\t}\n\tif contains(methods, methodOllamaLocal) {\n\t\tvalues[\"OLLAMA_API_BASE\"] = strings.TrimSpace(ollamaAPIBase)\n\t\tvalues[\"OLLAMA_MODEL\"] = strings.TrimSpace(ollamaModel)\n\t}\n\tif contains(methods, methodOllamaCloud) {\n\t\tvalues[\"OLLAMA_CLOUD_API_BASE\"] = strings.TrimSpace(ollamaCloudAPIBase)\n\t\tvalues[\"OLLAMA_CLOUD_API_KEY\"] = strings.TrimSpace(ollamaCloudAPIKey)\n\t\tvalues[\"OLLAMA_CLOUD_MODEL\"] = strings.TrimSpace(ollamaCloudModel)\n\t}\n\tif contains(methods, methodBedrockAPI) {\n\t\tvalues[\"AWS_ACCESS_KEY_ID\"] = strings.TrimSpace(awsAccessKeyID)\n\t\tvalues[\"AWS_SECRET_ACCESS_KEY\"] = strings.TrimSpace(awsSecretAccessKey)\n\t\t// LiteLLM's Bedrock provider reads AWS_REGION_NAME, not the\n\t\t// boto3-standard AWS_REGION. Set both for SDK compatibility.\n\t\tvalues[\"AWS_REGION_NAME\"] = strings.TrimSpace(awsRegion)\n\t\tvalues[\"AWS_REGION\"] = strings.TrimSpace(awsRegion)\n\t}\n\tif contains(methods, methodVertexAPI) {\n\t\tvalues[\"GOOGLE_APPLICATION_CREDENTIALS\"] = strings.TrimSpace(vertexCredsPath)\n\t\tvalues[\"VERTEXAI_PROJECT\"] = strings.TrimSpace(vertexProject)\n\t\tvalues[\"VERTEXAI_LOCATION\"] = strings.TrimSpace(vertexLocation)\n\t}\n\tif contains(methods, methodAzureAPI) {\n\t\tvalues[\"AZURE_API_KEY\"] = strings.TrimSpace(azureAPIKey)\n\t\tvalues[\"AZURE_API_BASE\"] = strings.TrimSpace(azureAPIBase)\n\t\tvalues[\"AZURE_API_VERSION\"] = strings.TrimSpace(azureAPIVersion)\n\t}\n\tif githubToken != \"\" {\n\t\t// LiteLLM's github/ provider reads GITHUB_API_KEY; the GitHub\n\t\t// CLI + most docs use GITHUB_TOKEN. Set both so either path\n\t\t// works without operator surprise.\n\t\tvalues[\"GITHUB_TOKEN\"] = strings.TrimSpace(githubToken)\n\t\tvalues[\"GITHUB_API_KEY\"] = strings.TrimSpace(githubToken)\n\t}\n\tif groqKey != \"\" {\n\t\tvalues[\"GROQ_API_KEY\"] = strings.TrimSpace(groqKey)\n\t}\n\tif togetherKey != \"\" {\n\t\tvalues[\"TOGETHER_API_KEY\"] = strings.TrimSpace(togetherKey)\n\t}\n\tif fireworksKey != \"\" {\n\t\tvalues[\"FIREWORKS_API_KEY\"] = strings.TrimSpace(fireworksKey)\n\t}\n\tif cohereKey != \"\" {\n\t\tvalues[\"COHERE_API_KEY\"] = strings.TrimSpace(cohereKey)\n\t}\n\tif moonshotKey != \"\" {\n\t\tvalues[\"MOONSHOT_API_KEY\"] = strings.TrimSpace(moonshotKey)\n\t}\n\tif zaiKey != \"\" {\n\t\tvalues[\"ZAI_API_KEY\"] = strings.TrimSpace(zaiKey)\n\t}\n\tif dashscopeKey != \"\" {\n\t\tvalues[\"DASHSCOPE_API_KEY\"] = strings.TrimSpace(dashscopeKey)\n\t}\n\tif contains(methods, methodLMStudioLocal) {\n\t\tvalues[\"LMSTUDIO_API_BASE\"] = strings.TrimSpace(lmStudioAPIBase)\n\t\tvalues[\"LMSTUDIO_MODEL\"] = strings.TrimSpace(lmStudioModel)\n\t\t// LM Studio accepts any string as the API key; set a sentinel\n\t\t// so LiteLLM's openai-compatible shim doesn't fail an empty\n\t\t// Authorization header.\n\t\tvalues[\"LMSTUDIO_API_KEY\"] = \"lm-studio\"\n\t}\n\tif contains(methods, methodCustomOpenAIAPI) {\n\t\tvalues[\"CUSTOM_OPENAI_API_BASE\"] = strings.TrimSpace(customOpenAIAPIBase)\n\t\tvalues[\"CUSTOM_OPENAI_API_KEY\"] = strings.TrimSpace(customOpenAIAPIKey)\n\t\tvalues[\"CUSTOM_OPENAI_MODEL\"] = strings.TrimSpace(customOpenAIModel)\n\t}\n\n\tif useLangSmith && langSmithKey != \"\" {\n\t\tvalues[\"LANGSMITH_TRACING\"] = \"true\"\n\t\tvalues[\"LANGSMITH_API_KEY\"] = langSmithKey\n\t\tvalues[\"LANGSMITH_PROJECT\"] = \"decepticon\"\n\t}\n\n\tif err := config.WriteEnvFromEmbed(config.EnvPath(), values); err != nil {\n\t\treturn fmt.Errorf(\"write .env: %w\", err)\n\t}\n\n\t// Summary\n\tfmt.Println()\n\tfmt.Println(ui.Green.Render(\" ✓ Configuration saved\"))\n\tfmt.Println()\n\tfmt.Println(ui.Dim.Render(\" ┌──────────────────────────────────┐\"))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Methods \") + ui.Dim.Render(strings.Join(priority, \", \")))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Profile \") + ui.Dim.Render(profile))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Language \") + ui.Dim.Render(language))\n\tif useLangSmith {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" LangSmith \") + ui.Green.Render(\"enabled\"))\n\t}\n\tfmt.Println(ui.Dim.Render(\" │\"))\n\tfmt.Println(ui.Dim.Render(\" │ \") + ui.Dim.Render(config.EnvPath()))\n\tfmt.Println(ui.Dim.Render(\" └──────────────────────────────────┘\"))\n\tfmt.Println()\n\tui.DimText(\" Run 'decepticon' to start the platform\")\n\treturn nil\n}\n\nfunc contains(haystack []string, needle string) bool {\n\treturn slices.Contains(haystack, needle)\n}\n\nfunc nonEmpty(s string) error {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn fmt.Errorf(\"value is required\")\n\t}\n\treturn nil\n}\n\nfunc boolStr(b bool) string {\n\tif b {\n\t\treturn \"true\"\n\t}\n\treturn \"false\"\n}\n\n// buildOllamaModelField returns the OLLAMA_MODEL form field that\n// matches the host probe outcome: strict Select when tool-capable\n// models are pulled, otherwise a remediation Note (different message\n// for the reachable-but-no-tools case vs unreachable Ollama).\nfunc buildOllamaModelField(probe ollamaProbeResult, selected *string) huh.Field {\n\tif len(probe.ToolCapableModels) > 0 {\n\t\toptions := make([]huh.Option[string], 0, len(probe.ToolCapableModels))\n\t\tfor _, m := range probe.ToolCapableModels {\n\t\t\toptions = append(options, huh.NewOption(m, m))\n\t\t}\n\t\tif !slices.Contains(probe.ToolCapableModels, *selected) {\n\t\t\t*selected = probe.ToolCapableModels[0]\n\t\t}\n\t\treturn huh.NewSelect[string]().\n\t\t\tTitle(\"OLLAMA_MODEL\").\n\t\t\tDescription(\"Tool-capable models found on your host. Decepticon\\nagents always emit tool calls — these are the only\\nmodels the wizard will accept.\").\n\t\t\tOptions(options...).\n\t\t\tValue(selected)\n\t}\n\n\tif probe.Reachable {\n\t\treturn huh.NewNote().\n\t\t\tTitle(\"OLLAMA_MODEL — no tool-capable models found\").\n\t\t\tDescription(\"Ollama is reachable but none of your pulled models\\nadvertise the 'tools' capability. Decepticon agents\\nalways emit tool calls, so a model without tool\\nsupport cannot power them.\\n\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n\t}\n\n\treturn huh.NewNote().\n\t\tTitle(\"OLLAMA_MODEL — Ollama not reachable\").\n\t\tDescription(\"Could not reach Ollama at \" + defaultOllamaAPIBase + \".\\n\\nMost likely Ollama isn't running or is bound to\\n127.0.0.1 only (which the Decepticon container can't\\nsee). Launch it on all interfaces, pull a tool-capable\\nmodel, then re-run the wizard:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n}\n\n// ollamaUnusableError surfaces the post-form remediation when the user\n// picked Ollama but the probe found nothing usable. Two flavors so the\n// hint matches whichever in-form Note was shown.\nfunc ollamaUnusableError(probe ollamaProbeResult, baseURL string) error {\n\tif probe.Reachable {\n\t\treturn fmt.Errorf(\n\t\t\t\"Ollama selected but no tool-capable models found on the host.\\n\" +\n\t\t\t\t\"Decepticon agents always emit tool calls — pull a tool-capable\\n\" +\n\t\t\t\t\"model and verify it advertises tools, then re-run:\\n\\n\" +\n\t\t\t\t\" ollama pull qwen3-coder:30b\\n\" +\n\t\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\" +\n\t\t\t\t\" decepticon onboard --reset\")\n\t}\n\treturn fmt.Errorf(\n\t\t\"Ollama selected but the host probe could not reach %s.\\n\"+\n\t\t\t\"Make sure Ollama is running and bound to all interfaces (the\\n\"+\n\t\t\t\"default 127.0.0.1 binding is invisible to containers), then\\n\"+\n\t\t\t\"pull a tool-capable model and re-run:\\n\\n\"+\n\t\t\t\" OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\"+\n\t\t\t\" ollama pull qwen3-coder:30b\\n\"+\n\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\"+\n\t\t\t\" decepticon onboard --reset\",\n\t\tbaseURL)\n}\n"}, "256cb0c139473dd06905bd2bb0d7c9275fcbfdf9": {"t": 1785755403.4361877, "n": 24863, "h": "8a4c6fe0cebe52e4", "c": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/huh/v2\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui\"\n\t\"github.com/spf13/cobra\"\n)\n\n// onboardOllamaProbeBudget bounds the wait for the background Ollama\n// discovery probe at form-construction time. Per-request timeout\n// (ollama_models.go) is much shorter; this is the worst-case across\n// all candidate URLs.\nconst onboardOllamaProbeBudget = 3 * time.Second\n\nvar resetFlag bool\n\nvar onboardCmd = &cobra.Command{\n\tUse: \"onboard\",\n\tShort: \"Configure Decepticon (auth methods, model profile, observability)\",\n\tRunE: runOnboard,\n}\n\nfunc init() {\n\tonboardCmd.Flags().BoolVar(&resetFlag, \"reset\", false, \"Reconfigure even if .env already exists\")\n\trootCmd.AddCommand(onboardCmd)\n}\n\n// AuthMethod identifiers — must match decepticon/llm/models.py::AuthMethod.\nconst (\n\tmethodAnthropicOAuth = \"anthropic_oauth\"\n\tmethodAnthropicAPI = \"anthropic_api\"\n\tmethodOpenAIOAuth = \"openai_oauth\"\n\tmethodOpenAIAPI = \"openai_api\"\n\tmethodGoogleOAuth = \"google_oauth\"\n\tmethodGoogleAPI = \"google_api\"\n\tmethodMiniMaxAPI = \"minimax_api\"\n\tmethodDeepSeekAPI = \"deepseek_api\"\n\tmethodXAIAPI = \"xai_api\"\n\tmethodGrokOAuth = \"grok_oauth\"\n\tmethodMistralAPI = \"mistral_api\"\n\tmethodOpenRouterAPI = \"openrouter_api\"\n\tmethodNvidiaAPI = \"nvidia_api\"\n\tmethodCopilotOAuth = \"copilot_oauth\"\n\tmethodPerplexityOAuth = \"perplexity_oauth\"\n\tmethodOllamaLocal = \"ollama_local\"\n)\n\n// Default Ollama wiring shown to OSS users. `host.docker.internal`\n// is the universal answer regardless of where Ollama runs (macOS host,\n// WSL2 distro, native Linux): from inside Decepticon's containers it\n// resolves to the host network namespace via the\n// `extra_hosts: [host.docker.internal:host-gateway]` entry on the\n// litellm service in docker-compose.yml. `localhost` is never the\n// right answer here — that's the container itself.\n//\n// The host's Ollama must additionally be bound to 0.0.0.0 (the default\n// 127.0.0.1 binding only accepts host-side connections); the wizard\n// surfaces this requirement to the user.\n//\n// The default model is the smallest/fastest one most laptops can\n// actually run; users with a GPU will pick something like qwen3-coder:30b.\nconst (\n\tdefaultOllamaAPIBase = \"http://host.docker.internal:11434\"\n\tdefaultOllamaModel = \"llama3.2\"\n)\n\n// methodOrder is the priority order surfaced in the wizard. The\n// resulting DECEPTICON_AUTH_PRIORITY preserves this order, filtered\n// to the methods the user actually selected. OAuth precedes the\n// matching API on purpose: a subscription primary should fall back\n// to the paid API only when the subscription quota is exhausted.\n// Ollama sits last: cloud providers are usually preferred when both\n// are configured, but a user wanting to lead with local-only inference\n// can reorder the priority manually in .env.\nvar methodOrder = []string{\n\tmethodAnthropicOAuth,\n\tmethodAnthropicAPI,\n\tmethodOpenAIOAuth,\n\tmethodOpenAIAPI,\n\tmethodGoogleOAuth,\n\tmethodGoogleAPI,\n\tmethodMiniMaxAPI,\n\tmethodDeepSeekAPI,\n\tmethodXAIAPI,\n\tmethodGrokOAuth,\n\tmethodMistralAPI,\n\tmethodOpenRouterAPI,\n\tmethodNvidiaAPI,\n\tmethodCopilotOAuth,\n\tmethodPerplexityOAuth,\n\tmethodOllamaLocal,\n}\n\nfunc runOnboard(cmd *cobra.Command, args []string) error {\n\tif config.EnvExists() && !resetFlag {\n\t\tui.Info(\".env already configured at \" + config.EnvPath())\n\t\tui.DimText(\"Run 'decepticon onboard --reset' to reconfigure\")\n\t\treturn nil\n\t}\n\n\t// Kick off Ollama discovery in the background so the network\n\t// round-trip overlaps with huh's startup work; the OLLAMA_MODEL\n\t// field type depends on the result (Select vs remediation Note).\n\tprobeCh := make(chan ollamaProbeResult, 1)\n\tgo func() {\n\t\tprobeCh <- probeOllamaForOnboard(defaultOllamaAPIBase)\n\t}()\n\n\tvar (\n\t\tmethods []string\n\t\tanthropicKey string\n\t\topenaiKey string\n\t\tgeminiKey string\n\t\tminimaxKey string\n\t\tdeepseekKey string\n\t\txaiKey string\n\t\tmistralKey string\n\t\topenrouterKey string\n\t\tnvidiaKey string\n\t\tgeminiSessionCookies string\n\t\tcopilotRefreshToken string\n\t\tgrokSessionToken string\n\t\tperplexitySessionToken string\n\t\tollamaAPIBase = defaultOllamaAPIBase\n\t\tollamaModel = defaultOllamaModel\n\t\tprofile string\n\t\tlanguage = \"en\"\n\t\tuseLangSmith bool\n\t\tlangSmithKey string\n\t)\n\t// Block on the probe (zero-value result on timeout means\n\t// \"unreachable\" — drops through to the remediation Note).\n\t// time.NewTimer + Stop avoids the time.After timer leak when the\n\t// probe finishes first.\n\tprobeTimer := time.NewTimer(onboardOllamaProbeBudget)\n\tvar ollamaProbe ollamaProbeResult\n\tselect {\n\tcase ollamaProbe = <-probeCh:\n\t\tprobeTimer.Stop()\n\tcase <-probeTimer.C:\n\t}\n\tollamaModelField := buildOllamaModelField(ollamaProbe, &ollamaModel)\n\n\tform := huh.NewForm(\n\t\t// Intro\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Decepticon Setup\").\n\t\t\t\tDescription(\"Configure auth methods, model profile, and\\nobservability.\\n\\nUse ↑↓ to navigate, space to toggle, Enter to confirm.\"),\n\t\t),\n\n\t\t// Step 1: Auth methods (multi-select)\n\t\thuh.NewGroup(\n\t\t\thuh.NewMultiSelect[string]().\n\t\t\t\tTitle(\"Auth Methods\").\n\t\t\t\tDescription(\"Pick every credential you have. Each method is an\\nindependent fallback in priority order shown.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"Claude Code OAuth — Anthropic subscription (auth/*)\", methodAnthropicOAuth),\n\t\t\t\t\thuh.NewOption(\"Anthropic API Key — sk-ant-...\", methodAnthropicAPI),\n\t\t\t\t\thuh.NewOption(\"ChatGPT OAuth — ChatGPT Pro/Plus/Team subscription (auth/gpt-*)\", methodOpenAIOAuth),\n\t\t\t\t\thuh.NewOption(\"OpenAI API Key — sk-...\", methodOpenAIAPI),\n\t\t\t\t\thuh.NewOption(\"Google API Key — AIza... (Gemini)\", methodGoogleAPI),\n\t\t\t\t\thuh.NewOption(\"MiniMax API Key — eyJ...\", methodMiniMaxAPI),\n\t\t\t\t\thuh.NewOption(\"DeepSeek API Key — sk-...\", methodDeepSeekAPI),\n\t\t\t\t\thuh.NewOption(\"xAI API Key — xai-... (Grok)\", methodXAIAPI),\n\t\t\t\t\thuh.NewOption(\"Mistral API Key — (no fixed prefix)\", methodMistralAPI),\n\t\t\t\t\thuh.NewOption(\"OpenRouter API Key — sk-or-...\", methodOpenRouterAPI),\n\t\t\t\t\thuh.NewOption(\"Nvidia NIM API Key — nvapi-...\", methodNvidiaAPI),\n\t\t\t\t\thuh.NewOption(\"Gemini Advanced — Google One AI Premium subscription (gemini-sub/*)\", methodGoogleOAuth),\n\t\t\t\t\thuh.NewOption(\"SuperGrok — X Premium+ Grok subscription (grok-sub/*)\", methodGrokOAuth),\n\t\t\t\t\thuh.NewOption(\"GitHub Copilot Pro — Copilot subscription (copilot/*)\", methodCopilotOAuth),\n\t\t\t\t\thuh.NewOption(\"Perplexity Pro — Perplexity subscription (pplx-sub/*)\", methodPerplexityOAuth),\n\t\t\t\t\thuh.NewOption(\"Local LLM (Ollama) — any pulled model, no API key\", methodOllamaLocal),\n\t\t\t\t).\n\t\t\t\tValue(&methods).\n\t\t\t\tValidate(func(s []string) error {\n\t\t\t\t\tif len(s) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"select at least one credential\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"1 / 5 · Credentials\").\n\t\t\tDescription(\"Select all that apply\"),\n\n\t\t// Step 2a: Anthropic API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Anthropic API Key\").\n\t\t\t\tPlaceholder(\"sk-ant-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&anthropicKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Anthropic API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicAPI) }),\n\n\t\t// Step 2c: OpenAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenAI API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenAI API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIAPI) }),\n\n\t\t// Step 2c: Google API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Google (Gemini) API Key\").\n\t\t\t\tPlaceholder(\"AIza...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Google API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleAPI) }),\n\n\t\t// Step 2d: MiniMax API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MiniMax API Key\").\n\t\t\t\tPlaceholder(\"eyJ...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&minimaxKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · MiniMax API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMiniMaxAPI) }),\n\n\t\t// Step 2d-i: DeepSeek API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DeepSeek API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&deepseekKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DeepSeek API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDeepSeekAPI) }),\n\n\t\t// Step 2d-ii: xAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"xAI API Key\").\n\t\t\t\tPlaceholder(\"xai-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&xaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · xAI API (Grok)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodXAIAPI) }),\n\n\t\t// Step 2d-iii: Mistral API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Mistral API Key\").\n\t\t\t\tPlaceholder(\"paste your Mistral API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&mistralKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Mistral API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMistralAPI) }),\n\n\t\t// Step 2e: OpenRouter API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenRouter API Key\").\n\t\t\t\tPlaceholder(\"sk-or-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openrouterKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenRouter API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenRouterAPI) }),\n\n\t\t// Step 2f: Nvidia NIM API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Nvidia NIM API Key\").\n\t\t\t\tPlaceholder(\"nvapi-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&nvidiaKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Nvidia NIM API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodNvidiaAPI) }),\n\n\t\t// Step 2-oauth-i: Gemini Advanced subscription\n\t\t// Multi-cookie value (NID + Secure-1PSID + Secure-1PSIDTS, etc.)\n\t\t// joined with semicolons. Optional — power users can drop a\n\t\t// tokens.json under ~/.config/gemini/ instead and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Gemini Advanced Subscription\").\n\t\t\t\tDescription(\"Open gemini.google.com → DevTools → Application →\\nCookies → gemini.google.com. Copy the values of\\nNID, __Secure-1PSID, __Secure-1PSIDTS as a single\\nsemicolon-joined string. Or leave blank to use\\nGEMINI_ACCESS_TOKEN / ~/.config/gemini/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GEMINI_SESSION_COOKIES\").\n\t\t\t\tPlaceholder(\"NID=...; __Secure-1PSID=... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiSessionCookies),\n\t\t).Title(\"2 / 5 · Gemini Advanced\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleOAuth) }),\n\n\t\t// Step 2-oauth-ii: SuperGrok subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"SuperGrok Subscription\").\n\t\t\t\tDescription(\"Open grok.com → DevTools → Application →\\nCookies → grok.com → copy the value of\\n`auth_token`. Or leave blank to use\\nGROK_ACCESS_TOKEN / ~/.config/grok/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROK_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste auth_token cookie value (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&grokSessionToken),\n\t\t).Title(\"2 / 5 · SuperGrok\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGrokOAuth) }),\n\n\t\t// Step 2-oauth-iii: GitHub Copilot Pro subscription\n\t\t// Refresh token rotation, not a single session cookie. Devs can\n\t\t// extract one from VSCode's Copilot extension config or follow\n\t\t// gh-copilot-cli onboarding. Optional via tokens.json fallback.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GitHub Copilot Pro Subscription\").\n\t\t\t\tDescription(\"Provide a Copilot refresh token (long-lived, used\\nto rotate access tokens). Or leave blank to use\\nCOPILOT_ACCESS_TOKEN / ~/.config/copilot/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COPILOT_REFRESH_TOKEN\").\n\t\t\t\tPlaceholder(\"ghu_... or ghr_... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&copilotRefreshToken),\n\t\t).Title(\"2 / 5 · Copilot Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCopilotOAuth) }),\n\n\t\t// Step 2-oauth-iv: Perplexity Pro subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Perplexity Pro Subscription\").\n\t\t\t\tDescription(\"Open perplexity.ai → DevTools → Application →\\nCookies → perplexity.ai → copy the value of\\n`next-auth.session-token`. Or leave blank to use\\nPERPLEXITY_ACCESS_TOKEN / ~/.config/perplexity/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"PERPLEXITY_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste next-auth.session-token (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&perplexitySessionToken),\n\t\t).Title(\"2 / 5 · Perplexity Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodPerplexityOAuth) }),\n\n\t\t// Step 2g: Local Ollama. The OLLAMA_MODEL field is built from\n\t\t// the host probe (buildOllamaModelField): a strict Select when\n\t\t// tool-capable models are pulled, a remediation Note otherwise.\n\t\t// The post-form gate refuses to write .env in the Note cases.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Local Ollama\").\n\t\t\t\tDescription(\"Ollama must already be running on your host with\\nat least one tool-capable model pulled. Launch it\\nbound to all interfaces so the Decepticon container\\ncan reach it:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\\nThe default 127.0.0.1 binding only accepts host-side\\nconnections — containers won't see it.\\n\\nThe default URL `host.docker.internal:11434` works\\non macOS, Linux, and WSL2 (with or without Docker\\nDesktop). Only change it for remote / custom setups.\\nNote: the model list is probed against the default URL\\nat wizard start; if you customize OLLAMA_API_BASE,\\nfinish the wizard then re-run 'decepticon onboard\\n--reset' so the probe targets the new endpoint.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_API_BASE\").\n\t\t\t\tPlaceholder(defaultOllamaAPIBase).\n\t\t\t\tValue(&ollamaAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\tollamaModelField,\n\t\t).Title(\"2 / 5 · Local LLM (Ollama)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaLocal) }),\n\n\t\t// Step 3: Model profile\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Model Profile\").\n\t\t\t\tDescription(\"eco per-agent tier (recommended)\\nmax every agent on HIGH (expensive)\\ntest every agent on LOW (development)\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"eco — per-agent tier (recommended)\", \"eco\"),\n\t\t\t\t\thuh.NewOption(\"max — every agent on HIGH (expensive)\", \"max\"),\n\t\t\t\t\thuh.NewOption(\"test — every agent on LOW (development)\", \"test\"),\n\t\t\t\t).\n\t\t\t\tValue(&profile),\n\t\t).Title(\"3 / 5 · Profile\"),\n\n\t\t// Step 4: Language\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Agent Language\").\n\t\t\t\tDescription(\"Language for all agent prose output (menus, questions,\\nsummaries, errors). Technical output stays in English.\\nCountry codes (dk, se, jp, cn) are auto-resolved.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"en — English (default)\", \"en\"),\n\t\t\t\t\thuh.NewOption(\"no — Norwegian\", \"no\"),\n\t\t\t\t\thuh.NewOption(\"da — Danish\", \"da\"),\n\t\t\t\t\thuh.NewOption(\"sv — Swedish\", \"sv\"),\n\t\t\t\t\thuh.NewOption(\"fi — Finnish\", \"fi\"),\n\t\t\t\t\thuh.NewOption(\"is — Icelandic\", \"is\"),\n\t\t\t\t\thuh.NewOption(\"ko — Korean\", \"ko\"),\n\t\t\t\t\thuh.NewOption(\"ja — Japanese\", \"ja\"),\n\t\t\t\t\thuh.NewOption(\"zh — Chinese\", \"zh\"),\n\t\t\t\t\thuh.NewOption(\"zh-tw — Traditional Chinese\", \"zh-tw\"),\n\t\t\t\t\thuh.NewOption(\"es — Spanish\", \"es\"),\n\t\t\t\t\thuh.NewOption(\"pt — Portuguese\", \"pt\"),\n\t\t\t\t\thuh.NewOption(\"pt-br — Brazilian Portuguese\", \"pt-br\"),\n\t\t\t\t\thuh.NewOption(\"de — German\", \"de\"),\n\t\t\t\t\thuh.NewOption(\"fr — French\", \"fr\"),\n\t\t\t\t\thuh.NewOption(\"nl — Dutch\", \"nl\"),\n\t\t\t\t\thuh.NewOption(\"it — Italian\", \"it\"),\n\t\t\t\t\thuh.NewOption(\"pl — Polish\", \"pl\"),\n\t\t\t\t\thuh.NewOption(\"cs — Czech\", \"cs\"),\n\t\t\t\t\thuh.NewOption(\"uk — Ukrainian\", \"uk\"),\n\t\t\t\t\thuh.NewOption(\"ro — Romanian\", \"ro\"),\n\t\t\t\t\thuh.NewOption(\"hr — Croatian\", \"hr\"),\n\t\t\t\t\thuh.NewOption(\"bg — Bulgarian\", \"bg\"),\n\t\t\t\t\thuh.NewOption(\"ru — Russian\", \"ru\"),\n\t\t\t\t\thuh.NewOption(\"el — Greek\", \"el\"),\n\t\t\t\t\thuh.NewOption(\"hu — Hungarian\", \"hu\"),\n\t\t\t\t\thuh.NewOption(\"tr — Turkish\", \"tr\"),\n\t\t\t\t\thuh.NewOption(\"ar — Arabic\", \"ar\"),\n\t\t\t\t\thuh.NewOption(\"fa — Persian\", \"fa\"),\n\t\t\t\t\thuh.NewOption(\"he — Hebrew\", \"he\"),\n\t\t\t\t\thuh.NewOption(\"hi — Hindi\", \"hi\"),\n\t\t\t\t\thuh.NewOption(\"th — Thai\", \"th\"),\n\t\t\t\t\thuh.NewOption(\"vi — Vietnamese\", \"vi\"),\n\t\t\t\t\thuh.NewOption(\"id — Indonesian\", \"id\"),\n\t\t\t\t\thuh.NewOption(\"ms — Malay\", \"ms\"),\n\t\t\t\t\thuh.NewOption(\"tl — Filipino\", \"tl\"),\n\t\t\t\t\thuh.NewOption(\"sw — Swahili\", \"sw\"),\n\t\t\t\t\thuh.NewOption(\"af — Afrikaans\", \"af\"),\n\t\t\t\t\thuh.NewOption(\"wenyan — 文言文 + English technical terms\", \"wenyan\"),\n\t\t\t\t).\n\t\t\t\tValue(&language),\n\t\t).Title(\"4 / 5 · Language\"),\n\n\t\t// Step 5a: LangSmith toggle\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Enable LangSmith?\").\n\t\t\t\tDescription(\"LLM observability and trace collection\").\n\t\t\t\tAffirmative(\"Yes\").\n\t\t\t\tNegative(\"No\").\n\t\t\t\tValue(&useLangSmith),\n\t\t).Title(\"5 / 5 · Observability\"),\n\n\t\t// Step 5b: LangSmith key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LangSmith API Key\").\n\t\t\t\tPlaceholder(\"lsv2_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&langSmithKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"5 / 5 · LangSmith\").\n\t\t\tWithHideFunc(func() bool { return !useLangSmith }),\n\t).WithTheme(huh.ThemeFunc(ui.DecepticonTheme))\n\n\tif err := form.Run(); err != nil {\n\t\treturn fmt.Errorf(\"setup cancelled: %w\", err)\n\t}\n\n\t// Strict-mode gate: refuse to write .env when the user picked\n\t// Ollama but the host probe found no tool-capable model. The\n\t// in-form Note shows the same remediation; this is the boundary\n\t// guarantee that a broken setup never ships.\n\tif contains(methods, methodOllamaLocal) && len(ollamaProbe.ToolCapableModels) == 0 {\n\t\treturn ollamaUnusableError(ollamaProbe, ollamaAPIBase)\n\t}\n\n\t// huh.MultiSelect returns selected values in option order, not the\n\t// order the user toggled. Re-derive the priority by walking\n\t// methodOrder and keeping only what the user picked.\n\tpriority := make([]string, 0, len(methods))\n\tfor _, m := range methodOrder {\n\t\tif contains(methods, m) {\n\t\t\tpriority = append(priority, m)\n\t\t}\n\t}\n\n\tvalues := map[string]string{\n\t\t\"DECEPTICON_MODEL_PROFILE\": profile,\n\t\t\"DECEPTICON_LANGUAGE\": language,\n\t\t\"DECEPTICON_AUTH_PRIORITY\": strings.Join(priority, \",\"),\n\t\t\"DECEPTICON_AUTH_CLAUDE_CODE\": boolStr(contains(methods, methodAnthropicOAuth)),\n\t\t\"DECEPTICON_AUTH_CHATGPT\": boolStr(contains(methods, methodOpenAIOAuth)),\n\t\t\"DECEPTICON_AUTH_GEMINI\": boolStr(contains(methods, methodGoogleOAuth)),\n\t\t\"DECEPTICON_AUTH_GROK\": boolStr(contains(methods, methodGrokOAuth)),\n\t\t\"DECEPTICON_AUTH_COPILOT\": boolStr(contains(methods, methodCopilotOAuth)),\n\t\t\"DECEPTICON_AUTH_PERPLEXITY\": boolStr(contains(methods, methodPerplexityOAuth)),\n\t}\n\n\tif anthropicKey != \"\" {\n\t\tvalues[\"ANTHROPIC_API_KEY\"] = anthropicKey\n\t}\n\tif openaiKey != \"\" {\n\t\tvalues[\"OPENAI_API_KEY\"] = openaiKey\n\t}\n\tif geminiKey != \"\" {\n\t\tvalues[\"GEMINI_API_KEY\"] = geminiKey\n\t}\n\tif minimaxKey != \"\" {\n\t\tvalues[\"MINIMAX_API_KEY\"] = minimaxKey\n\t}\n\tif deepseekKey != \"\" {\n\t\tvalues[\"DEEPSEEK_API_KEY\"] = deepseekKey\n\t}\n\tif xaiKey != \"\" {\n\t\tvalues[\"XAI_API_KEY\"] = xaiKey\n\t}\n\tif mistralKey != \"\" {\n\t\tvalues[\"MISTRAL_API_KEY\"] = mistralKey\n\t}\n\tif openrouterKey != \"\" {\n\t\tvalues[\"OPENROUTER_API_KEY\"] = openrouterKey\n\t}\n\tif nvidiaKey != \"\" {\n\t\tvalues[\"NVIDIA_API_KEY\"] = nvidiaKey\n\t}\n\tif geminiSessionCookies != \"\" {\n\t\tvalues[\"GEMINI_SESSION_COOKIES\"] = geminiSessionCookies\n\t}\n\tif grokSessionToken != \"\" {\n\t\tvalues[\"GROK_SESSION_TOKEN\"] = grokSessionToken\n\t}\n\tif copilotRefreshToken != \"\" {\n\t\tvalues[\"COPILOT_REFRESH_TOKEN\"] = copilotRefreshToken\n\t}\n\tif perplexitySessionToken != \"\" {\n\t\tvalues[\"PERPLEXITY_SESSION_TOKEN\"] = perplexitySessionToken\n\t}\n\tif contains(methods, methodOllamaLocal) {\n\t\tvalues[\"OLLAMA_API_BASE\"] = strings.TrimSpace(ollamaAPIBase)\n\t\tvalues[\"OLLAMA_MODEL\"] = strings.TrimSpace(ollamaModel)\n\t}\n\n\tif useLangSmith && langSmithKey != \"\" {\n\t\tvalues[\"LANGSMITH_TRACING\"] = \"true\"\n\t\tvalues[\"LANGSMITH_API_KEY\"] = langSmithKey\n\t\tvalues[\"LANGSMITH_PROJECT\"] = \"decepticon\"\n\t}\n\n\tif err := config.WriteEnvFromEmbed(config.EnvPath(), values); err != nil {\n\t\treturn fmt.Errorf(\"write .env: %w\", err)\n\t}\n\n\t// Summary\n\tfmt.Println()\n\tfmt.Println(ui.Green.Render(\" ✓ Configuration saved\"))\n\tfmt.Println()\n\tfmt.Println(ui.Dim.Render(\" ┌──────────────────────────────────┐\"))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Methods \") + ui.Dim.Render(strings.Join(priority, \", \")))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Profile \") + ui.Dim.Render(profile))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Language \") + ui.Dim.Render(language))\n\tif useLangSmith {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" LangSmith \") + ui.Green.Render(\"enabled\"))\n\t}\n\tfmt.Println(ui.Dim.Render(\" │\"))\n\tfmt.Println(ui.Dim.Render(\" │ \") + ui.Dim.Render(config.EnvPath()))\n\tfmt.Println(ui.Dim.Render(\" └──────────────────────────────────┘\"))\n\tfmt.Println()\n\tui.DimText(\" Run 'decepticon' to start the platform\")\n\treturn nil\n}\n\nfunc contains(haystack []string, needle string) bool {\n\treturn slices.Contains(haystack, needle)\n}\n\nfunc nonEmpty(s string) error {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn fmt.Errorf(\"value is required\")\n\t}\n\treturn nil\n}\n\nfunc boolStr(b bool) string {\n\tif b {\n\t\treturn \"true\"\n\t}\n\treturn \"false\"\n}\n\n// buildOllamaModelField returns the OLLAMA_MODEL form field that\n// matches the host probe outcome: strict Select when tool-capable\n// models are pulled, otherwise a remediation Note (different message\n// for the reachable-but-no-tools case vs unreachable Ollama).\nfunc buildOllamaModelField(probe ollamaProbeResult, selected *string) huh.Field {\n\tif len(probe.ToolCapableModels) > 0 {\n\t\toptions := make([]huh.Option[string], 0, len(probe.ToolCapableModels))\n\t\tfor _, m := range probe.ToolCapableModels {\n\t\t\toptions = append(options, huh.NewOption(m, m))\n\t\t}\n\t\tif !slices.Contains(probe.ToolCapableModels, *selected) {\n\t\t\t*selected = probe.ToolCapableModels[0]\n\t\t}\n\t\treturn huh.NewSelect[string]().\n\t\t\tTitle(\"OLLAMA_MODEL\").\n\t\t\tDescription(\"Tool-capable models found on your host. Decepticon\\nagents always emit tool calls — these are the only\\nmodels the wizard will accept.\").\n\t\t\tOptions(options...).\n\t\t\tValue(selected)\n\t}\n\n\tif probe.Reachable {\n\t\treturn huh.NewNote().\n\t\t\tTitle(\"OLLAMA_MODEL — no tool-capable models found\").\n\t\t\tDescription(\"Ollama is reachable but none of your pulled models\\nadvertise the 'tools' capability. Decepticon agents\\nalways emit tool calls, so a model without tool\\nsupport cannot power them.\\n\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n\t}\n\n\treturn huh.NewNote().\n\t\tTitle(\"OLLAMA_MODEL — Ollama not reachable\").\n\t\tDescription(\"Could not reach Ollama at \" + defaultOllamaAPIBase + \".\\n\\nMost likely Ollama isn't running or is bound to\\n127.0.0.1 only (which the Decepticon container can't\\nsee). Launch it on all interfaces, pull a tool-capable\\nmodel, then re-run the wizard:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n}\n\n// ollamaUnusableError surfaces the post-form remediation when the user\n// picked Ollama but the probe found nothing usable. Two flavors so the\n// hint matches whichever in-form Note was shown.\nfunc ollamaUnusableError(probe ollamaProbeResult, baseURL string) error {\n\tif probe.Reachable {\n\t\treturn fmt.Errorf(\n\t\t\t\"Ollama selected but no tool-capable models found on the host.\\n\" +\n\t\t\t\t\"Decepticon agents always emit tool calls — pull a tool-capable\\n\" +\n\t\t\t\t\"model and verify it advertises tools, then re-run:\\n\\n\" +\n\t\t\t\t\" ollama pull qwen3-coder:30b\\n\" +\n\t\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\" +\n\t\t\t\t\" decepticon onboard --reset\")\n\t}\n\treturn fmt.Errorf(\n\t\t\"Ollama selected but the host probe could not reach %s.\\n\"+\n\t\t\t\"Make sure Ollama is running and bound to all interfaces (the\\n\"+\n\t\t\t\"default 127.0.0.1 binding is invisible to containers), then\\n\"+\n\t\t\t\"pull a tool-capable model and re-run:\\n\\n\"+\n\t\t\t\" OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\"+\n\t\t\t\" ollama pull qwen3-coder:30b\\n\"+\n\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\"+\n\t\t\t\" decepticon onboard --reset\",\n\t\tbaseURL)\n}\n"}, "e5ec6b7b3141d0e89cb2c7868d03091566131fac": {"t": 1785755405.845394, "n": 26013, "h": "5afaa8c6f38be1a2", "c": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/huh/v2\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui\"\n\t\"github.com/spf13/cobra\"\n)\n\n// onboardOllamaProbeBudget bounds the wait for the background Ollama\n// discovery probe at form-construction time. Per-request timeout\n// (ollama_models.go) is much shorter; this is the worst-case across\n// all candidate URLs.\nconst onboardOllamaProbeBudget = 3 * time.Second\n\nvar resetFlag bool\n\nvar onboardCmd = &cobra.Command{\n\tUse: \"onboard\",\n\tShort: \"Configure Decepticon (auth methods, model profile, observability)\",\n\tRunE: runOnboard,\n}\n\nfunc init() {\n\tonboardCmd.Flags().BoolVar(&resetFlag, \"reset\", false, \"Reconfigure even if .env already exists\")\n\trootCmd.AddCommand(onboardCmd)\n}\n\n// AuthMethod identifiers — must match decepticon/llm/models.py::AuthMethod.\nconst (\n\tmethodAnthropicOAuth = \"anthropic_oauth\"\n\tmethodAnthropicAPI = \"anthropic_api\"\n\tmethodOpenAIOAuth = \"openai_oauth\"\n\tmethodOpenAIAPI = \"openai_api\"\n\tmethodGoogleOAuth = \"google_oauth\"\n\tmethodGoogleAPI = \"google_api\"\n\tmethodMiniMaxAPI = \"minimax_api\"\n\tmethodDeepSeekAPI = \"deepseek_api\"\n\tmethodXAIAPI = \"xai_api\"\n\tmethodGrokOAuth = \"grok_oauth\"\n\tmethodMistralAPI = \"mistral_api\"\n\tmethodOpenRouterAPI = \"openrouter_api\"\n\tmethodNvidiaAPI = \"nvidia_api\"\n\tmethodCopilotOAuth = \"copilot_oauth\"\n\tmethodPerplexityOAuth = \"perplexity_oauth\"\n\tmethodOllamaLocal = \"ollama_local\"\n)\n\n// Default Ollama wiring shown to OSS users. ``host.docker.internal``\n// is the universal answer regardless of where Ollama runs (macOS host,\n// WSL2 distro, native Linux): from inside Decepticon's containers it\n// resolves to the host network namespace via the\n// ``extra_hosts: [host.docker.internal:host-gateway]`` entry on the\n// litellm service in docker-compose.yml. ``localhost`` is never the\n// right answer here — that's the container itself.\n//\n// The host's Ollama must additionally be bound to 0.0.0.0 (the default\n// 127.0.0.1 binding only accepts host-side connections); the wizard\n// surfaces this requirement to the user.\n//\n// The default model is the smallest/fastest one most laptops can\n// actually run; users with a GPU will pick something like qwen3-coder:30b.\nconst (\n\tdefaultOllamaAPIBase = \"http://host.docker.internal:11434\"\n\tdefaultOllamaModel = \"llama3.2\"\n)\n\n// methodOrder is the priority order surfaced in the wizard. The\n// resulting DECEPTICON_AUTH_PRIORITY preserves this order, filtered\n// to the methods the user actually selected. OAuth precedes the\n// matching API on purpose: a subscription primary should fall back\n// to the paid API only when the subscription quota is exhausted.\n// Ollama sits last: cloud providers are usually preferred when both\n// are configured, but a user wanting to lead with local-only inference\n// can reorder the priority manually in .env.\nvar methodOrder = []string{\n\tmethodAnthropicOAuth,\n\tmethodAnthropicAPI,\n\tmethodOpenAIOAuth,\n\tmethodOpenAIAPI,\n\tmethodGoogleOAuth,\n\tmethodGoogleAPI,\n\tmethodMiniMaxAPI,\n\tmethodDeepSeekAPI,\n\tmethodXAIAPI,\n\tmethodGrokOAuth,\n\tmethodMistralAPI,\n\tmethodOpenRouterAPI,\n\tmethodNvidiaAPI,\n\tmethodCopilotOAuth,\n\tmethodPerplexityOAuth,\n\tmethodOllamaLocal,\n}\n\nfunc runOnboard(cmd *cobra.Command, args []string) error {\n\tif config.EnvExists() && !resetFlag {\n\t\tui.Info(\".env already configured at \" + config.EnvPath())\n\t\tui.DimText(\"Run 'decepticon onboard --reset' to reconfigure\")\n\t\treturn nil\n\t}\n\n\t// Kick off Ollama discovery in the background so the network\n\t// round-trip overlaps with huh's startup work; the OLLAMA_MODEL\n\t// field type depends on the result (Select vs remediation Note).\n\tprobeCh := make(chan ollamaProbeResult, 1)\n\tgo func() {\n\t\tprobeCh <- probeOllamaForOnboard(defaultOllamaAPIBase)\n\t}()\n\n\tvar (\n\t\tmethods []string\n\t\tanthropicKey string\n\t\topenaiKey string\n\t\tgeminiKey string\n\t\tminimaxKey string\n\t\tdeepseekKey string\n\t\txaiKey string\n\t\tmistralKey string\n\t\topenrouterKey string\n\t\tnvidiaKey string\n\t\tchatgptSessionToken string\n\t\tgeminiSessionCookies string\n\t\tcopilotRefreshToken string\n\t\tgrokSessionToken string\n\t\tperplexitySessionToken string\n\t\tollamaAPIBase = defaultOllamaAPIBase\n\t\tollamaModel = defaultOllamaModel\n\t\tprofile string\n\t\tlanguage = \"en\"\n\t\tuseLangSmith bool\n\t\tlangSmithKey string\n\t)\n\t// Block on the probe (zero-value result on timeout means\n\t// \"unreachable\" — drops through to the remediation Note).\n\t// time.NewTimer + Stop avoids the time.After timer leak when the\n\t// probe finishes first.\n\tprobeTimer := time.NewTimer(onboardOllamaProbeBudget)\n\tvar ollamaProbe ollamaProbeResult\n\tselect {\n\tcase ollamaProbe = <-probeCh:\n\t\tprobeTimer.Stop()\n\tcase <-probeTimer.C:\n\t}\n\tollamaModelField := buildOllamaModelField(ollamaProbe, &ollamaModel)\n\n\tform := huh.NewForm(\n\t\t// Intro\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Decepticon Setup\").\n\t\t\t\tDescription(\"Configure auth methods, model profile, and\\nobservability.\\n\\nUse ↑↓ to navigate, space to toggle, Enter to confirm.\"),\n\t\t),\n\n\t\t// Step 1: Auth methods (multi-select)\n\t\thuh.NewGroup(\n\t\t\thuh.NewMultiSelect[string]().\n\t\t\t\tTitle(\"Auth Methods\").\n\t\t\t\tDescription(\"Pick every credential you have. Each method is an\\nindependent fallback in priority order shown.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"Claude Code OAuth — Anthropic subscription (auth/*)\", methodAnthropicOAuth),\n\t\t\t\t\thuh.NewOption(\"Anthropic API Key — sk-ant-...\", methodAnthropicAPI),\n\t\t\t\t\thuh.NewOption(\"ChatGPT OAuth — ChatGPT Pro/Plus/Team subscription (auth/gpt-*)\", methodOpenAIOAuth),\n\t\t\t\t\thuh.NewOption(\"OpenAI API Key — sk-...\", methodOpenAIAPI),\n\t\t\t\t\thuh.NewOption(\"Google API Key — AIza... (Gemini)\", methodGoogleAPI),\n\t\t\t\t\thuh.NewOption(\"MiniMax API Key — eyJ...\", methodMiniMaxAPI),\n\t\t\t\t\thuh.NewOption(\"DeepSeek API Key — sk-...\", methodDeepSeekAPI),\n\t\t\t\t\thuh.NewOption(\"xAI API Key — xai-... (Grok)\", methodXAIAPI),\n\t\t\t\t\thuh.NewOption(\"Mistral API Key — (no fixed prefix)\", methodMistralAPI),\n\t\t\t\t\thuh.NewOption(\"OpenRouter API Key — sk-or-...\", methodOpenRouterAPI),\n\t\t\t\t\thuh.NewOption(\"Nvidia NIM API Key — nvapi-...\", methodNvidiaAPI),\n\t\t\t\t\thuh.NewOption(\"Gemini Advanced — Google One AI Premium subscription (gemini-sub/*)\", methodGoogleOAuth),\n\t\t\t\t\thuh.NewOption(\"SuperGrok — X Premium+ Grok subscription (grok-sub/*)\", methodGrokOAuth),\n\t\t\t\t\thuh.NewOption(\"GitHub Copilot Pro — Copilot subscription (copilot/*)\", methodCopilotOAuth),\n\t\t\t\t\thuh.NewOption(\"Perplexity Pro — Perplexity subscription (pplx-sub/*)\", methodPerplexityOAuth),\n\t\t\t\t\thuh.NewOption(\"Local LLM (Ollama) — any pulled model, no API key\", methodOllamaLocal),\n\t\t\t\t).\n\t\t\t\tValue(&methods).\n\t\t\t\tValidate(func(s []string) error {\n\t\t\t\t\tif len(s) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"select at least one credential\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"1 / 5 · Credentials\").\n\t\t\tDescription(\"Select all that apply\"),\n\n\t\t// Step 2a: Anthropic API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Anthropic API Key\").\n\t\t\t\tPlaceholder(\"sk-ant-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&anthropicKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Anthropic API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicAPI) }),\n\n\t\t// Step 2b: ChatGPT subscription session token\n\t\t// ChatGPT OAuth has no equivalent of `claude /login` to provision\n\t\t// tokens automatically, so we ask the user to paste the\n\t\t// `__Secure-next-auth.session-token` cookie from a signed-in\n\t\t// chatgpt.com browser session. Optional — users who set\n\t\t// CHATGPT_ACCESS_TOKEN externally or place ~/.config/litellm/chatgpt/tokens.json\n\t\t// can leave this blank and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"ChatGPT Session Token\").\n\t\t\t\tDescription(\"Open chatgpt.com → DevTools → Application →\\nCookies → chatgpt.com → copy the value of\\n`__Secure-next-auth.session-token`. Or leave\\nblank to use CHATGPT_ACCESS_TOKEN / tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CHATGPT_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"eyJhbGciOiJ... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&chatgptSessionToken),\n\t\t).Title(\"2 / 5 · ChatGPT OAuth\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIOAuth) }),\n\n\t\t// Step 2c: OpenAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenAI API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenAI API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIAPI) }),\n\n\t\t// Step 2c: Google API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Google (Gemini) API Key\").\n\t\t\t\tPlaceholder(\"AIza...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Google API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleAPI) }),\n\n\t\t// Step 2d: MiniMax API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MiniMax API Key\").\n\t\t\t\tPlaceholder(\"eyJ...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&minimaxKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · MiniMax API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMiniMaxAPI) }),\n\n\t\t// Step 2d-i: DeepSeek API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DeepSeek API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&deepseekKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DeepSeek API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDeepSeekAPI) }),\n\n\t\t// Step 2d-ii: xAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"xAI API Key\").\n\t\t\t\tPlaceholder(\"xai-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&xaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · xAI API (Grok)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodXAIAPI) }),\n\n\t\t// Step 2d-iii: Mistral API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Mistral API Key\").\n\t\t\t\tPlaceholder(\"paste your Mistral API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&mistralKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Mistral API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMistralAPI) }),\n\n\t\t// Step 2e: OpenRouter API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenRouter API Key\").\n\t\t\t\tPlaceholder(\"sk-or-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openrouterKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenRouter API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenRouterAPI) }),\n\n\t\t// Step 2f: Nvidia NIM API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Nvidia NIM API Key\").\n\t\t\t\tPlaceholder(\"nvapi-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&nvidiaKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Nvidia NIM API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodNvidiaAPI) }),\n\n\t\t// Step 2-oauth-i: Gemini Advanced subscription\n\t\t// Multi-cookie value (NID + Secure-1PSID + Secure-1PSIDTS, etc.)\n\t\t// joined with semicolons. Optional — power users can drop a\n\t\t// tokens.json under ~/.config/gemini/ instead and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Gemini Advanced Subscription\").\n\t\t\t\tDescription(\"Open gemini.google.com → DevTools → Application →\\nCookies → gemini.google.com. Copy the values of\\nNID, __Secure-1PSID, __Secure-1PSIDTS as a single\\nsemicolon-joined string. Or leave blank to use\\nGEMINI_ACCESS_TOKEN / ~/.config/gemini/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GEMINI_SESSION_COOKIES\").\n\t\t\t\tPlaceholder(\"NID=...; __Secure-1PSID=... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiSessionCookies),\n\t\t).Title(\"2 / 5 · Gemini Advanced\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleOAuth) }),\n\n\t\t// Step 2-oauth-ii: SuperGrok subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"SuperGrok Subscription\").\n\t\t\t\tDescription(\"Open grok.com → DevTools → Application →\\nCookies → grok.com → copy the value of\\n`auth_token`. Or leave blank to use\\nGROK_ACCESS_TOKEN / ~/.config/grok/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROK_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste auth_token cookie value (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&grokSessionToken),\n\t\t).Title(\"2 / 5 · SuperGrok\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGrokOAuth) }),\n\n\t\t// Step 2-oauth-iii: GitHub Copilot Pro subscription\n\t\t// Refresh token rotation, not a single session cookie. Devs can\n\t\t// extract one from VSCode's Copilot extension config or follow\n\t\t// gh-copilot-cli onboarding. Optional via tokens.json fallback.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GitHub Copilot Pro Subscription\").\n\t\t\t\tDescription(\"Provide a Copilot refresh token (long-lived, used\\nto rotate access tokens). Or leave blank to use\\nCOPILOT_ACCESS_TOKEN / ~/.config/copilot/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COPILOT_REFRESH_TOKEN\").\n\t\t\t\tPlaceholder(\"ghu_... or ghr_... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&copilotRefreshToken),\n\t\t).Title(\"2 / 5 · Copilot Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCopilotOAuth) }),\n\n\t\t// Step 2-oauth-iv: Perplexity Pro subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Perplexity Pro Subscription\").\n\t\t\t\tDescription(\"Open perplexity.ai → DevTools → Application →\\nCookies → perplexity.ai → copy the value of\\n`next-auth.session-token`. Or leave blank to use\\nPERPLEXITY_ACCESS_TOKEN / ~/.config/perplexity/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"PERPLEXITY_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste next-auth.session-token (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&perplexitySessionToken),\n\t\t).Title(\"2 / 5 · Perplexity Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodPerplexityOAuth) }),\n\n\t\t// Step 2g: Local Ollama. The OLLAMA_MODEL field is built from\n\t\t// the host probe (buildOllamaModelField): a strict Select when\n\t\t// tool-capable models are pulled, a remediation Note otherwise.\n\t\t// The post-form gate refuses to write .env in the Note cases.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Local Ollama\").\n\t\t\t\tDescription(\"Ollama must already be running on your host with\\nat least one tool-capable model pulled. Launch it\\nbound to all interfaces so the Decepticon container\\ncan reach it:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\\nThe default 127.0.0.1 binding only accepts host-side\\nconnections — containers won't see it.\\n\\nThe default URL `host.docker.internal:11434` works\\non macOS, Linux, and WSL2 (with or without Docker\\nDesktop). Only change it for remote / custom setups.\\nNote: the model list is probed against the default URL\\nat wizard start; if you customize OLLAMA_API_BASE,\\nfinish the wizard then re-run 'decepticon onboard\\n--reset' so the probe targets the new endpoint.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_API_BASE\").\n\t\t\t\tPlaceholder(defaultOllamaAPIBase).\n\t\t\t\tValue(&ollamaAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\tollamaModelField,\n\t\t).Title(\"2 / 5 · Local LLM (Ollama)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaLocal) }),\n\n\t\t// Step 3: Model profile\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Model Profile\").\n\t\t\t\tDescription(\"eco per-agent tier (recommended)\\nmax every agent on HIGH (expensive)\\ntest every agent on LOW (development)\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"eco — per-agent tier (recommended)\", \"eco\"),\n\t\t\t\t\thuh.NewOption(\"max — every agent on HIGH (expensive)\", \"max\"),\n\t\t\t\t\thuh.NewOption(\"test — every agent on LOW (development)\", \"test\"),\n\t\t\t\t).\n\t\t\t\tValue(&profile),\n\t\t).Title(\"3 / 5 · Profile\"),\n\n\t\t// Step 4: Language\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Agent Language\").\n\t\t\t\tDescription(\"Language for all agent prose output (menus, questions,\\nsummaries, errors). Technical output stays in English.\\nCountry codes (dk, se, jp, cn) are auto-resolved.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"en — English (default)\", \"en\"),\n\t\t\t\t\thuh.NewOption(\"no — Norwegian\", \"no\"),\n\t\t\t\t\thuh.NewOption(\"da — Danish\", \"da\"),\n\t\t\t\t\thuh.NewOption(\"sv — Swedish\", \"sv\"),\n\t\t\t\t\thuh.NewOption(\"fi — Finnish\", \"fi\"),\n\t\t\t\t\thuh.NewOption(\"is — Icelandic\", \"is\"),\n\t\t\t\t\thuh.NewOption(\"ko — Korean\", \"ko\"),\n\t\t\t\t\thuh.NewOption(\"ja — Japanese\", \"ja\"),\n\t\t\t\t\thuh.NewOption(\"zh — Chinese\", \"zh\"),\n\t\t\t\t\thuh.NewOption(\"zh-tw — Traditional Chinese\", \"zh-tw\"),\n\t\t\t\t\thuh.NewOption(\"es — Spanish\", \"es\"),\n\t\t\t\t\thuh.NewOption(\"pt — Portuguese\", \"pt\"),\n\t\t\t\t\thuh.NewOption(\"pt-br — Brazilian Portuguese\", \"pt-br\"),\n\t\t\t\t\thuh.NewOption(\"de — German\", \"de\"),\n\t\t\t\t\thuh.NewOption(\"fr — French\", \"fr\"),\n\t\t\t\t\thuh.NewOption(\"nl — Dutch\", \"nl\"),\n\t\t\t\t\thuh.NewOption(\"it — Italian\", \"it\"),\n\t\t\t\t\thuh.NewOption(\"pl — Polish\", \"pl\"),\n\t\t\t\t\thuh.NewOption(\"cs — Czech\", \"cs\"),\n\t\t\t\t\thuh.NewOption(\"uk — Ukrainian\", \"uk\"),\n\t\t\t\t\thuh.NewOption(\"ro — Romanian\", \"ro\"),\n\t\t\t\t\thuh.NewOption(\"hr — Croatian\", \"hr\"),\n\t\t\t\t\thuh.NewOption(\"bg — Bulgarian\", \"bg\"),\n\t\t\t\t\thuh.NewOption(\"ru — Russian\", \"ru\"),\n\t\t\t\t\thuh.NewOption(\"el — Greek\", \"el\"),\n\t\t\t\t\thuh.NewOption(\"hu — Hungarian\", \"hu\"),\n\t\t\t\t\thuh.NewOption(\"tr — Turkish\", \"tr\"),\n\t\t\t\t\thuh.NewOption(\"ar — Arabic\", \"ar\"),\n\t\t\t\t\thuh.NewOption(\"fa — Persian\", \"fa\"),\n\t\t\t\t\thuh.NewOption(\"he — Hebrew\", \"he\"),\n\t\t\t\t\thuh.NewOption(\"hi — Hindi\", \"hi\"),\n\t\t\t\t\thuh.NewOption(\"th — Thai\", \"th\"),\n\t\t\t\t\thuh.NewOption(\"vi — Vietnamese\", \"vi\"),\n\t\t\t\t\thuh.NewOption(\"id — Indonesian\", \"id\"),\n\t\t\t\t\thuh.NewOption(\"ms — Malay\", \"ms\"),\n\t\t\t\t\thuh.NewOption(\"tl — Filipino\", \"tl\"),\n\t\t\t\t\thuh.NewOption(\"sw — Swahili\", \"sw\"),\n\t\t\t\t\thuh.NewOption(\"af — Afrikaans\", \"af\"),\n\t\t\t\t\thuh.NewOption(\"wenyan — 文言文 + English technical terms\", \"wenyan\"),\n\t\t\t\t).\n\t\t\t\tValue(&language),\n\t\t).Title(\"4 / 5 · Language\"),\n\n\t\t// Step 5a: LangSmith toggle\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Enable LangSmith?\").\n\t\t\t\tDescription(\"LLM observability and trace collection\").\n\t\t\t\tAffirmative(\"Yes\").\n\t\t\t\tNegative(\"No\").\n\t\t\t\tValue(&useLangSmith),\n\t\t).Title(\"5 / 5 · Observability\"),\n\n\t\t// Step 5b: LangSmith key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LangSmith API Key\").\n\t\t\t\tPlaceholder(\"lsv2_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&langSmithKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"5 / 5 · LangSmith\").\n\t\t\tWithHideFunc(func() bool { return !useLangSmith }),\n\t).WithTheme(huh.ThemeFunc(ui.DecepticonTheme))\n\n\tif err := form.Run(); err != nil {\n\t\treturn fmt.Errorf(\"setup cancelled: %w\", err)\n\t}\n\n\t// Strict-mode gate: refuse to write .env when the user picked\n\t// Ollama but the host probe found no tool-capable model. The\n\t// in-form Note shows the same remediation; this is the boundary\n\t// guarantee that a broken setup never ships.\n\tif contains(methods, methodOllamaLocal) && len(ollamaProbe.ToolCapableModels) == 0 {\n\t\treturn ollamaUnusableError(ollamaProbe, ollamaAPIBase)\n\t}\n\n\t// huh.MultiSelect returns selected values in option order, not the\n\t// order the user toggled. Re-derive the priority by walking\n\t// methodOrder and keeping only what the user picked.\n\tpriority := make([]string, 0, len(methods))\n\tfor _, m := range methodOrder {\n\t\tif contains(methods, m) {\n\t\t\tpriority = append(priority, m)\n\t\t}\n\t}\n\n\tvalues := map[string]string{\n\t\t\"DECEPTICON_MODEL_PROFILE\": profile,\n\t\t\"DECEPTICON_LANGUAGE\": language,\n\t\t\"DECEPTICON_AUTH_PRIORITY\": strings.Join(priority, \",\"),\n\t\t\"DECEPTICON_AUTH_CLAUDE_CODE\": boolStr(contains(methods, methodAnthropicOAuth)),\n\t\t\"DECEPTICON_AUTH_CHATGPT\": boolStr(contains(methods, methodOpenAIOAuth)),\n\t\t\"DECEPTICON_AUTH_GEMINI\": boolStr(contains(methods, methodGoogleOAuth)),\n\t\t\"DECEPTICON_AUTH_GROK\": boolStr(contains(methods, methodGrokOAuth)),\n\t\t\"DECEPTICON_AUTH_COPILOT\": boolStr(contains(methods, methodCopilotOAuth)),\n\t\t\"DECEPTICON_AUTH_PERPLEXITY\": boolStr(contains(methods, methodPerplexityOAuth)),\n\t}\n\n\tif anthropicKey != \"\" {\n\t\tvalues[\"ANTHROPIC_API_KEY\"] = anthropicKey\n\t}\n\tif openaiKey != \"\" {\n\t\tvalues[\"OPENAI_API_KEY\"] = openaiKey\n\t}\n\tif geminiKey != \"\" {\n\t\tvalues[\"GEMINI_API_KEY\"] = geminiKey\n\t}\n\tif minimaxKey != \"\" {\n\t\tvalues[\"MINIMAX_API_KEY\"] = minimaxKey\n\t}\n\tif deepseekKey != \"\" {\n\t\tvalues[\"DEEPSEEK_API_KEY\"] = deepseekKey\n\t}\n\tif xaiKey != \"\" {\n\t\tvalues[\"XAI_API_KEY\"] = xaiKey\n\t}\n\tif mistralKey != \"\" {\n\t\tvalues[\"MISTRAL_API_KEY\"] = mistralKey\n\t}\n\tif openrouterKey != \"\" {\n\t\tvalues[\"OPENROUTER_API_KEY\"] = openrouterKey\n\t}\n\tif nvidiaKey != \"\" {\n\t\tvalues[\"NVIDIA_API_KEY\"] = nvidiaKey\n\t}\n\tif chatgptSessionToken != \"\" {\n\t\tvalues[\"CHATGPT_SESSION_TOKEN\"] = chatgptSessionToken\n\t}\n\tif geminiSessionCookies != \"\" {\n\t\tvalues[\"GEMINI_SESSION_COOKIES\"] = geminiSessionCookies\n\t}\n\tif grokSessionToken != \"\" {\n\t\tvalues[\"GROK_SESSION_TOKEN\"] = grokSessionToken\n\t}\n\tif copilotRefreshToken != \"\" {\n\t\tvalues[\"COPILOT_REFRESH_TOKEN\"] = copilotRefreshToken\n\t}\n\tif perplexitySessionToken != \"\" {\n\t\tvalues[\"PERPLEXITY_SESSION_TOKEN\"] = perplexitySessionToken\n\t}\n\tif contains(methods, methodOllamaLocal) {\n\t\tvalues[\"OLLAMA_API_BASE\"] = strings.TrimSpace(ollamaAPIBase)\n\t\tvalues[\"OLLAMA_MODEL\"] = strings.TrimSpace(ollamaModel)\n\t}\n\n\tif useLangSmith && langSmithKey != \"\" {\n\t\tvalues[\"LANGSMITH_TRACING\"] = \"true\"\n\t\tvalues[\"LANGSMITH_API_KEY\"] = langSmithKey\n\t\tvalues[\"LANGSMITH_PROJECT\"] = \"decepticon\"\n\t}\n\n\tif err := config.WriteEnvFromEmbed(config.EnvPath(), values); err != nil {\n\t\treturn fmt.Errorf(\"write .env: %w\", err)\n\t}\n\n\t// Summary\n\tfmt.Println()\n\tfmt.Println(ui.Green.Render(\" ✓ Configuration saved\"))\n\tfmt.Println()\n\tfmt.Println(ui.Dim.Render(\" ┌──────────────────────────────────┐\"))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Methods \") + ui.Dim.Render(strings.Join(priority, \", \")))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Profile \") + ui.Dim.Render(profile))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Language \") + ui.Dim.Render(language))\n\tif useLangSmith {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" LangSmith \") + ui.Green.Render(\"enabled\"))\n\t}\n\tfmt.Println(ui.Dim.Render(\" │\"))\n\tfmt.Println(ui.Dim.Render(\" │ \") + ui.Dim.Render(config.EnvPath()))\n\tfmt.Println(ui.Dim.Render(\" └──────────────────────────────────┘\"))\n\tfmt.Println()\n\tui.DimText(\" Run 'decepticon' to start the platform\")\n\treturn nil\n}\n\nfunc contains(haystack []string, needle string) bool {\n\treturn slices.Contains(haystack, needle)\n}\n\nfunc nonEmpty(s string) error {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn fmt.Errorf(\"value is required\")\n\t}\n\treturn nil\n}\n\nfunc boolStr(b bool) string {\n\tif b {\n\t\treturn \"true\"\n\t}\n\treturn \"false\"\n}\n\n// buildOllamaModelField returns the OLLAMA_MODEL form field that\n// matches the host probe outcome: strict Select when tool-capable\n// models are pulled, otherwise a remediation Note (different message\n// for the reachable-but-no-tools case vs unreachable Ollama).\nfunc buildOllamaModelField(probe ollamaProbeResult, selected *string) huh.Field {\n\tif len(probe.ToolCapableModels) > 0 {\n\t\toptions := make([]huh.Option[string], 0, len(probe.ToolCapableModels))\n\t\tfor _, m := range probe.ToolCapableModels {\n\t\t\toptions = append(options, huh.NewOption(m, m))\n\t\t}\n\t\tif !slices.Contains(probe.ToolCapableModels, *selected) {\n\t\t\t*selected = probe.ToolCapableModels[0]\n\t\t}\n\t\treturn huh.NewSelect[string]().\n\t\t\tTitle(\"OLLAMA_MODEL\").\n\t\t\tDescription(\"Tool-capable models found on your host. Decepticon\\nagents always emit tool calls — these are the only\\nmodels the wizard will accept.\").\n\t\t\tOptions(options...).\n\t\t\tValue(selected)\n\t}\n\n\tif probe.Reachable {\n\t\treturn huh.NewNote().\n\t\t\tTitle(\"OLLAMA_MODEL — no tool-capable models found\").\n\t\t\tDescription(\"Ollama is reachable but none of your pulled models\\nadvertise the 'tools' capability. Decepticon agents\\nalways emit tool calls, so a model without tool\\nsupport cannot power them.\\n\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n\t}\n\n\treturn huh.NewNote().\n\t\tTitle(\"OLLAMA_MODEL — Ollama not reachable\").\n\t\tDescription(\"Could not reach Ollama at \" + defaultOllamaAPIBase + \".\\n\\nMost likely Ollama isn't running or is bound to\\n127.0.0.1 only (which the Decepticon container can't\\nsee). Launch it on all interfaces, pull a tool-capable\\nmodel, then re-run the wizard:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n}\n\n// ollamaUnusableError surfaces the post-form remediation when the user\n// picked Ollama but the probe found nothing usable. Two flavors so the\n// hint matches whichever in-form Note was shown.\nfunc ollamaUnusableError(probe ollamaProbeResult, baseURL string) error {\n\tif probe.Reachable {\n\t\treturn fmt.Errorf(\n\t\t\t\"Ollama selected but no tool-capable models found on the host.\\n\" +\n\t\t\t\t\"Decepticon agents always emit tool calls — pull a tool-capable\\n\" +\n\t\t\t\t\"model and verify it advertises tools, then re-run:\\n\\n\" +\n\t\t\t\t\" ollama pull qwen3-coder:30b\\n\" +\n\t\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\" +\n\t\t\t\t\" decepticon onboard --reset\")\n\t}\n\treturn fmt.Errorf(\n\t\t\"Ollama selected but the host probe could not reach %s.\\n\"+\n\t\t\t\"Make sure Ollama is running and bound to all interfaces (the\\n\"+\n\t\t\t\"default 127.0.0.1 binding is invisible to containers), then\\n\"+\n\t\t\t\"pull a tool-capable model and re-run:\\n\\n\"+\n\t\t\t\" OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\"+\n\t\t\t\" ollama pull qwen3-coder:30b\\n\"+\n\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\"+\n\t\t\t\" decepticon onboard --reset\",\n\t\tbaseURL)\n}\n"}, "f62646fd9c1626c36d9f7f9b91191f23749ddd5e": {"t": 1785755407.4970806, "n": 26005, "h": "719a66bd1f3b3fb6", "c": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/huh/v2\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui\"\n\t\"github.com/spf13/cobra\"\n)\n\n// onboardOllamaProbeBudget bounds the wait for the background Ollama\n// discovery probe at form-construction time. Per-request timeout\n// (ollama_models.go) is much shorter; this is the worst-case across\n// all candidate URLs.\nconst onboardOllamaProbeBudget = 3 * time.Second\n\nvar resetFlag bool\n\nvar onboardCmd = &cobra.Command{\n\tUse: \"onboard\",\n\tShort: \"Configure Decepticon (auth methods, model profile, observability)\",\n\tRunE: runOnboard,\n}\n\nfunc init() {\n\tonboardCmd.Flags().BoolVar(&resetFlag, \"reset\", false, \"Reconfigure even if .env already exists\")\n\trootCmd.AddCommand(onboardCmd)\n}\n\n// AuthMethod identifiers — must match decepticon/llm/models.py::AuthMethod.\nconst (\n\tmethodAnthropicOAuth = \"anthropic_oauth\"\n\tmethodAnthropicAPI = \"anthropic_api\"\n\tmethodOpenAIOAuth = \"openai_oauth\"\n\tmethodOpenAIAPI = \"openai_api\"\n\tmethodGoogleOAuth = \"google_oauth\"\n\tmethodGoogleAPI = \"google_api\"\n\tmethodMiniMaxAPI = \"minimax_api\"\n\tmethodDeepSeekAPI = \"deepseek_api\"\n\tmethodXAIAPI = \"xai_api\"\n\tmethodGrokOAuth = \"grok_oauth\"\n\tmethodMistralAPI = \"mistral_api\"\n\tmethodOpenRouterAPI = \"openrouter_api\"\n\tmethodNvidiaAPI = \"nvidia_api\"\n\tmethodCopilotOAuth = \"copilot_oauth\"\n\tmethodPerplexityOAuth = \"perplexity_oauth\"\n\tmethodOllamaLocal = \"ollama_local\"\n)\n\n// Default Ollama wiring shown to OSS users. ``host.docker.internal``\n// is the universal answer regardless of where Ollama runs (macOS host,\n// WSL2 distro, native Linux): from inside Decepticon's containers it\n// resolves to the host network namespace via the\n// ``extra_hosts: [host.docker.internal:host-gateway]`` entry on the\n// litellm service in docker-compose.yml. ``localhost`` is never the\n// right answer here — that's the container itself.\n//\n// The host's Ollama must additionally be bound to 0.0.0.0 (the default\n// 127.0.0.1 binding only accepts host-side connections); the wizard\n// surfaces this requirement to the user.\n//\n// The default model is the smallest/fastest one most laptops can\n// actually run; users with a GPU will pick something like qwen3-coder:30b.\nconst (\n\tdefaultOllamaAPIBase = \"http://host.docker.internal:11434\"\n\tdefaultOllamaModel = \"llama3.2\"\n)\n\n// methodOrder is the priority order surfaced in the wizard. The\n// resulting DECEPTICON_AUTH_PRIORITY preserves this order, filtered\n// to the methods the user actually selected. OAuth precedes the\n// matching API on purpose: a subscription primary should fall back\n// to the paid API only when the subscription quota is exhausted.\n// Ollama sits last: cloud providers are usually preferred when both\n// are configured, but a user wanting to lead with local-only inference\n// can reorder the priority manually in .env.\nvar methodOrder = []string{\n\tmethodAnthropicOAuth,\n\tmethodAnthropicAPI,\n\tmethodOpenAIOAuth,\n\tmethodOpenAIAPI,\n\tmethodGoogleOAuth,\n\tmethodGoogleAPI,\n\tmethodMiniMaxAPI,\n\tmethodDeepSeekAPI,\n\tmethodXAIAPI,\n\tmethodGrokOAuth,\n\tmethodMistralAPI,\n\tmethodOpenRouterAPI,\n\tmethodNvidiaAPI,\n\tmethodCopilotOAuth,\n\tmethodPerplexityOAuth,\n\tmethodOllamaLocal,\n}\n\nfunc runOnboard(cmd *cobra.Command, args []string) error {\n\tif config.EnvExists() && !resetFlag {\n\t\tui.Info(\".env already configured at \" + config.EnvPath())\n\t\tui.DimText(\"Run 'decepticon onboard --reset' to reconfigure\")\n\t\treturn nil\n\t}\n\n\t// Kick off Ollama discovery in the background so the network\n\t// round-trip overlaps with huh's startup work; the OLLAMA_MODEL\n\t// field type depends on the result (Select vs remediation Note).\n\tprobeCh := make(chan ollamaProbeResult, 1)\n\tgo func() {\n\t\tprobeCh <- probeOllamaForOnboard(defaultOllamaAPIBase)\n\t}()\n\n\tvar (\n\t\tmethods []string\n\t\tanthropicKey string\n\t\topenaiKey string\n\t\tgeminiKey string\n\t\tminimaxKey string\n\t\tdeepseekKey string\n\t\txaiKey string\n\t\tmistralKey string\n\t\topenrouterKey string\n\t\tnvidiaKey string\n\t\tchatgptSessionToken string\n\t\tgeminiSessionCookies string\n\t\tcopilotRefreshToken string\n\t\tgrokSessionToken string\n\t\tperplexitySessionToken string\n\t\tollamaAPIBase = defaultOllamaAPIBase\n\t\tollamaModel = defaultOllamaModel\n\t\tprofile string\n\t\tlanguage = \"en\"\n\t\tuseLangSmith bool\n\t\tlangSmithKey string\n\t)\n\t// Block on the probe (zero-value result on timeout means\n\t// \"unreachable\" — drops through to the remediation Note).\n\t// time.NewTimer + Stop avoids the time.After timer leak when the\n\t// probe finishes first.\n\tprobeTimer := time.NewTimer(onboardOllamaProbeBudget)\n\tvar ollamaProbe ollamaProbeResult\n\tselect {\n\tcase ollamaProbe = <-probeCh:\n\t\tprobeTimer.Stop()\n\tcase <-probeTimer.C:\n\t}\n\tollamaModelField := buildOllamaModelField(ollamaProbe, &ollamaModel)\n\n\tform := huh.NewForm(\n\t\t// Intro\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Decepticon Setup\").\n\t\t\t\tDescription(\"Configure auth methods, model profile, and\\nobservability.\\n\\nUse ↑↓ to navigate, space to toggle, Enter to confirm.\"),\n\t\t),\n\n\t\t// Step 1: Auth methods (multi-select)\n\t\thuh.NewGroup(\n\t\t\thuh.NewMultiSelect[string]().\n\t\t\t\tTitle(\"Auth Methods\").\n\t\t\t\tDescription(\"Pick every credential you have. Each method is an\\nindependent fallback in priority order shown.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"Claude Code OAuth — Anthropic subscription (auth/*)\", methodAnthropicOAuth),\n\t\t\t\t\thuh.NewOption(\"Anthropic API Key — sk-ant-...\", methodAnthropicAPI),\n\t\t\t\t\thuh.NewOption(\"ChatGPT OAuth — ChatGPT Pro/Plus/Team subscription (auth/gpt-*)\", methodOpenAIOAuth),\n\t\t\t\t\thuh.NewOption(\"OpenAI API Key — sk-...\", methodOpenAIAPI),\n\t\t\t\t\thuh.NewOption(\"Google API Key — AIza... (Gemini)\", methodGoogleAPI),\n\t\t\t\t\thuh.NewOption(\"MiniMax API Key — eyJ...\", methodMiniMaxAPI),\n\t\t\t\t\thuh.NewOption(\"DeepSeek API Key — sk-...\", methodDeepSeekAPI),\n\t\t\t\t\thuh.NewOption(\"xAI API Key — xai-... (Grok)\", methodXAIAPI),\n\t\t\t\t\thuh.NewOption(\"Mistral API Key — (no fixed prefix)\", methodMistralAPI),\n\t\t\t\t\thuh.NewOption(\"OpenRouter API Key — sk-or-...\", methodOpenRouterAPI),\n\t\t\t\t\thuh.NewOption(\"Nvidia NIM API Key — nvapi-...\", methodNvidiaAPI),\n\t\t\t\t\thuh.NewOption(\"Gemini Advanced — Google One AI Premium subscription (gemini-sub/*)\", methodGoogleOAuth),\n\t\t\t\t\thuh.NewOption(\"SuperGrok — X Premium+ Grok subscription (grok-sub/*)\", methodGrokOAuth),\n\t\t\t\t\thuh.NewOption(\"GitHub Copilot Pro — Copilot subscription (copilot/*)\", methodCopilotOAuth),\n\t\t\t\t\thuh.NewOption(\"Perplexity Pro — Perplexity subscription (pplx-sub/*)\", methodPerplexityOAuth),\n\t\t\t\t\thuh.NewOption(\"Local LLM (Ollama) — any pulled model, no API key\", methodOllamaLocal),\n\t\t\t\t).\n\t\t\t\tValue(&methods).\n\t\t\t\tValidate(func(s []string) error {\n\t\t\t\t\tif len(s) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"select at least one credential\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"1 / 5 · Credentials\").\n\t\t\tDescription(\"Select all that apply\"),\n\n\t\t// Step 2a: Anthropic API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Anthropic API Key\").\n\t\t\t\tPlaceholder(\"sk-ant-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&anthropicKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Anthropic API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicAPI) }),\n\n\t\t// Step 2b: ChatGPT subscription session token\n\t\t// ChatGPT OAuth has no equivalent of `claude /login` to provision\n\t\t// tokens automatically, so we ask the user to paste the\n\t\t// `__Secure-next-auth.session-token` cookie from a signed-in\n\t\t// chatgpt.com browser session. Optional — users who set\n\t\t// CHATGPT_ACCESS_TOKEN externally or place ~/.config/chatgpt/tokens.json\n\t\t// can leave this blank and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"ChatGPT Session Token\").\n\t\t\t\tDescription(\"Open chatgpt.com → DevTools → Application →\\nCookies → chatgpt.com → copy the value of\\n`__Secure-next-auth.session-token`. Or leave\\nblank to use CHATGPT_ACCESS_TOKEN / tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CHATGPT_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"eyJhbGciOiJ... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&chatgptSessionToken),\n\t\t).Title(\"2 / 5 · ChatGPT OAuth\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIOAuth) }),\n\n\t\t// Step 2c: OpenAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenAI API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenAI API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIAPI) }),\n\n\t\t// Step 2c: Google API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Google (Gemini) API Key\").\n\t\t\t\tPlaceholder(\"AIza...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Google API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleAPI) }),\n\n\t\t// Step 2d: MiniMax API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MiniMax API Key\").\n\t\t\t\tPlaceholder(\"eyJ...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&minimaxKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · MiniMax API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMiniMaxAPI) }),\n\n\t\t// Step 2d-i: DeepSeek API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DeepSeek API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&deepseekKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · DeepSeek API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDeepSeekAPI) }),\n\n\t\t// Step 2d-ii: xAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"xAI API Key\").\n\t\t\t\tPlaceholder(\"xai-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&xaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · xAI API (Grok)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodXAIAPI) }),\n\n\t\t// Step 2d-iii: Mistral API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Mistral API Key\").\n\t\t\t\tPlaceholder(\"paste your Mistral API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&mistralKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Mistral API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMistralAPI) }),\n\n\t\t// Step 2e: OpenRouter API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenRouter API Key\").\n\t\t\t\tPlaceholder(\"sk-or-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openrouterKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · OpenRouter API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenRouterAPI) }),\n\n\t\t// Step 2f: Nvidia NIM API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Nvidia NIM API Key\").\n\t\t\t\tPlaceholder(\"nvapi-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&nvidiaKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 5 · Nvidia NIM API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodNvidiaAPI) }),\n\n\t\t// Step 2-oauth-i: Gemini Advanced subscription\n\t\t// Multi-cookie value (NID + Secure-1PSID + Secure-1PSIDTS, etc.)\n\t\t// joined with semicolons. Optional — power users can drop a\n\t\t// tokens.json under ~/.config/gemini/ instead and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Gemini Advanced Subscription\").\n\t\t\t\tDescription(\"Open gemini.google.com → DevTools → Application →\\nCookies → gemini.google.com. Copy the values of\\nNID, __Secure-1PSID, __Secure-1PSIDTS as a single\\nsemicolon-joined string. Or leave blank to use\\nGEMINI_ACCESS_TOKEN / ~/.config/gemini/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GEMINI_SESSION_COOKIES\").\n\t\t\t\tPlaceholder(\"NID=...; __Secure-1PSID=... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiSessionCookies),\n\t\t).Title(\"2 / 5 · Gemini Advanced\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleOAuth) }),\n\n\t\t// Step 2-oauth-ii: SuperGrok subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"SuperGrok Subscription\").\n\t\t\t\tDescription(\"Open grok.com → DevTools → Application →\\nCookies → grok.com → copy the value of\\n`auth_token`. Or leave blank to use\\nGROK_ACCESS_TOKEN / ~/.config/grok/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROK_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste auth_token cookie value (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&grokSessionToken),\n\t\t).Title(\"2 / 5 · SuperGrok\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGrokOAuth) }),\n\n\t\t// Step 2-oauth-iii: GitHub Copilot Pro subscription\n\t\t// Refresh token rotation, not a single session cookie. Devs can\n\t\t// extract one from VSCode's Copilot extension config or follow\n\t\t// gh-copilot-cli onboarding. Optional via tokens.json fallback.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GitHub Copilot Pro Subscription\").\n\t\t\t\tDescription(\"Provide a Copilot refresh token (long-lived, used\\nto rotate access tokens). Or leave blank to use\\nCOPILOT_ACCESS_TOKEN / ~/.config/copilot/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COPILOT_REFRESH_TOKEN\").\n\t\t\t\tPlaceholder(\"ghu_... or ghr_... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&copilotRefreshToken),\n\t\t).Title(\"2 / 5 · Copilot Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCopilotOAuth) }),\n\n\t\t// Step 2-oauth-iv: Perplexity Pro subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Perplexity Pro Subscription\").\n\t\t\t\tDescription(\"Open perplexity.ai → DevTools → Application →\\nCookies → perplexity.ai → copy the value of\\n`next-auth.session-token`. Or leave blank to use\\nPERPLEXITY_ACCESS_TOKEN / ~/.config/perplexity/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"PERPLEXITY_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste next-auth.session-token (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&perplexitySessionToken),\n\t\t).Title(\"2 / 5 · Perplexity Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodPerplexityOAuth) }),\n\n\t\t// Step 2g: Local Ollama. The OLLAMA_MODEL field is built from\n\t\t// the host probe (buildOllamaModelField): a strict Select when\n\t\t// tool-capable models are pulled, a remediation Note otherwise.\n\t\t// The post-form gate refuses to write .env in the Note cases.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Local Ollama\").\n\t\t\t\tDescription(\"Ollama must already be running on your host with\\nat least one tool-capable model pulled. Launch it\\nbound to all interfaces so the Decepticon container\\ncan reach it:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\\nThe default 127.0.0.1 binding only accepts host-side\\nconnections — containers won't see it.\\n\\nThe default URL `host.docker.internal:11434` works\\non macOS, Linux, and WSL2 (with or without Docker\\nDesktop). Only change it for remote / custom setups.\\nNote: the model list is probed against the default URL\\nat wizard start; if you customize OLLAMA_API_BASE,\\nfinish the wizard then re-run 'decepticon onboard\\n--reset' so the probe targets the new endpoint.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_API_BASE\").\n\t\t\t\tPlaceholder(defaultOllamaAPIBase).\n\t\t\t\tValue(&ollamaAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\tollamaModelField,\n\t\t).Title(\"2 / 5 · Local LLM (Ollama)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaLocal) }),\n\n\t\t// Step 3: Model profile\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Model Profile\").\n\t\t\t\tDescription(\"eco per-agent tier (recommended)\\nmax every agent on HIGH (expensive)\\ntest every agent on LOW (development)\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"eco — per-agent tier (recommended)\", \"eco\"),\n\t\t\t\t\thuh.NewOption(\"max — every agent on HIGH (expensive)\", \"max\"),\n\t\t\t\t\thuh.NewOption(\"test — every agent on LOW (development)\", \"test\"),\n\t\t\t\t).\n\t\t\t\tValue(&profile),\n\t\t).Title(\"3 / 5 · Profile\"),\n\n\t\t// Step 4: Language\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Agent Language\").\n\t\t\t\tDescription(\"Language for all agent prose output (menus, questions,\\nsummaries, errors). Technical output stays in English.\\nCountry codes (dk, se, jp, cn) are auto-resolved.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"en — English (default)\", \"en\"),\n\t\t\t\t\thuh.NewOption(\"no — Norwegian\", \"no\"),\n\t\t\t\t\thuh.NewOption(\"da — Danish\", \"da\"),\n\t\t\t\t\thuh.NewOption(\"sv — Swedish\", \"sv\"),\n\t\t\t\t\thuh.NewOption(\"fi — Finnish\", \"fi\"),\n\t\t\t\t\thuh.NewOption(\"is — Icelandic\", \"is\"),\n\t\t\t\t\thuh.NewOption(\"ko — Korean\", \"ko\"),\n\t\t\t\t\thuh.NewOption(\"ja — Japanese\", \"ja\"),\n\t\t\t\t\thuh.NewOption(\"zh — Chinese\", \"zh\"),\n\t\t\t\t\thuh.NewOption(\"zh-tw — Traditional Chinese\", \"zh-tw\"),\n\t\t\t\t\thuh.NewOption(\"es — Spanish\", \"es\"),\n\t\t\t\t\thuh.NewOption(\"pt — Portuguese\", \"pt\"),\n\t\t\t\t\thuh.NewOption(\"pt-br — Brazilian Portuguese\", \"pt-br\"),\n\t\t\t\t\thuh.NewOption(\"de — German\", \"de\"),\n\t\t\t\t\thuh.NewOption(\"fr — French\", \"fr\"),\n\t\t\t\t\thuh.NewOption(\"nl — Dutch\", \"nl\"),\n\t\t\t\t\thuh.NewOption(\"it — Italian\", \"it\"),\n\t\t\t\t\thuh.NewOption(\"pl — Polish\", \"pl\"),\n\t\t\t\t\thuh.NewOption(\"cs — Czech\", \"cs\"),\n\t\t\t\t\thuh.NewOption(\"uk — Ukrainian\", \"uk\"),\n\t\t\t\t\thuh.NewOption(\"ro — Romanian\", \"ro\"),\n\t\t\t\t\thuh.NewOption(\"hr — Croatian\", \"hr\"),\n\t\t\t\t\thuh.NewOption(\"bg — Bulgarian\", \"bg\"),\n\t\t\t\t\thuh.NewOption(\"ru — Russian\", \"ru\"),\n\t\t\t\t\thuh.NewOption(\"el — Greek\", \"el\"),\n\t\t\t\t\thuh.NewOption(\"hu — Hungarian\", \"hu\"),\n\t\t\t\t\thuh.NewOption(\"tr — Turkish\", \"tr\"),\n\t\t\t\t\thuh.NewOption(\"ar — Arabic\", \"ar\"),\n\t\t\t\t\thuh.NewOption(\"fa — Persian\", \"fa\"),\n\t\t\t\t\thuh.NewOption(\"he — Hebrew\", \"he\"),\n\t\t\t\t\thuh.NewOption(\"hi — Hindi\", \"hi\"),\n\t\t\t\t\thuh.NewOption(\"th — Thai\", \"th\"),\n\t\t\t\t\thuh.NewOption(\"vi — Vietnamese\", \"vi\"),\n\t\t\t\t\thuh.NewOption(\"id — Indonesian\", \"id\"),\n\t\t\t\t\thuh.NewOption(\"ms — Malay\", \"ms\"),\n\t\t\t\t\thuh.NewOption(\"tl — Filipino\", \"tl\"),\n\t\t\t\t\thuh.NewOption(\"sw — Swahili\", \"sw\"),\n\t\t\t\t\thuh.NewOption(\"af — Afrikaans\", \"af\"),\n\t\t\t\t\thuh.NewOption(\"wenyan — 文言文 + English technical terms\", \"wenyan\"),\n\t\t\t\t).\n\t\t\t\tValue(&language),\n\t\t).Title(\"4 / 5 · Language\"),\n\n\t\t// Step 5a: LangSmith toggle\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Enable LangSmith?\").\n\t\t\t\tDescription(\"LLM observability and trace collection\").\n\t\t\t\tAffirmative(\"Yes\").\n\t\t\t\tNegative(\"No\").\n\t\t\t\tValue(&useLangSmith),\n\t\t).Title(\"5 / 5 · Observability\"),\n\n\t\t// Step 5b: LangSmith key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LangSmith API Key\").\n\t\t\t\tPlaceholder(\"lsv2_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&langSmithKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"5 / 5 · LangSmith\").\n\t\t\tWithHideFunc(func() bool { return !useLangSmith }),\n\t).WithTheme(huh.ThemeFunc(ui.DecepticonTheme))\n\n\tif err := form.Run(); err != nil {\n\t\treturn fmt.Errorf(\"setup cancelled: %w\", err)\n\t}\n\n\t// Strict-mode gate: refuse to write .env when the user picked\n\t// Ollama but the host probe found no tool-capable model. The\n\t// in-form Note shows the same remediation; this is the boundary\n\t// guarantee that a broken setup never ships.\n\tif contains(methods, methodOllamaLocal) && len(ollamaProbe.ToolCapableModels) == 0 {\n\t\treturn ollamaUnusableError(ollamaProbe, ollamaAPIBase)\n\t}\n\n\t// huh.MultiSelect returns selected values in option order, not the\n\t// order the user toggled. Re-derive the priority by walking\n\t// methodOrder and keeping only what the user picked.\n\tpriority := make([]string, 0, len(methods))\n\tfor _, m := range methodOrder {\n\t\tif contains(methods, m) {\n\t\t\tpriority = append(priority, m)\n\t\t}\n\t}\n\n\tvalues := map[string]string{\n\t\t\"DECEPTICON_MODEL_PROFILE\": profile,\n\t\t\"DECEPTICON_LANGUAGE\": language,\n\t\t\"DECEPTICON_AUTH_PRIORITY\": strings.Join(priority, \",\"),\n\t\t\"DECEPTICON_AUTH_CLAUDE_CODE\": boolStr(contains(methods, methodAnthropicOAuth)),\n\t\t\"DECEPTICON_AUTH_CHATGPT\": boolStr(contains(methods, methodOpenAIOAuth)),\n\t\t\"DECEPTICON_AUTH_GEMINI\": boolStr(contains(methods, methodGoogleOAuth)),\n\t\t\"DECEPTICON_AUTH_GROK\": boolStr(contains(methods, methodGrokOAuth)),\n\t\t\"DECEPTICON_AUTH_COPILOT\": boolStr(contains(methods, methodCopilotOAuth)),\n\t\t\"DECEPTICON_AUTH_PERPLEXITY\": boolStr(contains(methods, methodPerplexityOAuth)),\n\t}\n\n\tif anthropicKey != \"\" {\n\t\tvalues[\"ANTHROPIC_API_KEY\"] = anthropicKey\n\t}\n\tif openaiKey != \"\" {\n\t\tvalues[\"OPENAI_API_KEY\"] = openaiKey\n\t}\n\tif geminiKey != \"\" {\n\t\tvalues[\"GEMINI_API_KEY\"] = geminiKey\n\t}\n\tif minimaxKey != \"\" {\n\t\tvalues[\"MINIMAX_API_KEY\"] = minimaxKey\n\t}\n\tif deepseekKey != \"\" {\n\t\tvalues[\"DEEPSEEK_API_KEY\"] = deepseekKey\n\t}\n\tif xaiKey != \"\" {\n\t\tvalues[\"XAI_API_KEY\"] = xaiKey\n\t}\n\tif mistralKey != \"\" {\n\t\tvalues[\"MISTRAL_API_KEY\"] = mistralKey\n\t}\n\tif openrouterKey != \"\" {\n\t\tvalues[\"OPENROUTER_API_KEY\"] = openrouterKey\n\t}\n\tif nvidiaKey != \"\" {\n\t\tvalues[\"NVIDIA_API_KEY\"] = nvidiaKey\n\t}\n\tif chatgptSessionToken != \"\" {\n\t\tvalues[\"CHATGPT_SESSION_TOKEN\"] = chatgptSessionToken\n\t}\n\tif geminiSessionCookies != \"\" {\n\t\tvalues[\"GEMINI_SESSION_COOKIES\"] = geminiSessionCookies\n\t}\n\tif grokSessionToken != \"\" {\n\t\tvalues[\"GROK_SESSION_TOKEN\"] = grokSessionToken\n\t}\n\tif copilotRefreshToken != \"\" {\n\t\tvalues[\"COPILOT_REFRESH_TOKEN\"] = copilotRefreshToken\n\t}\n\tif perplexitySessionToken != \"\" {\n\t\tvalues[\"PERPLEXITY_SESSION_TOKEN\"] = perplexitySessionToken\n\t}\n\tif contains(methods, methodOllamaLocal) {\n\t\tvalues[\"OLLAMA_API_BASE\"] = strings.TrimSpace(ollamaAPIBase)\n\t\tvalues[\"OLLAMA_MODEL\"] = strings.TrimSpace(ollamaModel)\n\t}\n\n\tif useLangSmith && langSmithKey != \"\" {\n\t\tvalues[\"LANGSMITH_TRACING\"] = \"true\"\n\t\tvalues[\"LANGSMITH_API_KEY\"] = langSmithKey\n\t\tvalues[\"LANGSMITH_PROJECT\"] = \"decepticon\"\n\t}\n\n\tif err := config.WriteEnvFromEmbed(config.EnvPath(), values); err != nil {\n\t\treturn fmt.Errorf(\"write .env: %w\", err)\n\t}\n\n\t// Summary\n\tfmt.Println()\n\tfmt.Println(ui.Green.Render(\" ✓ Configuration saved\"))\n\tfmt.Println()\n\tfmt.Println(ui.Dim.Render(\" ┌──────────────────────────────────┐\"))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Methods \") + ui.Dim.Render(strings.Join(priority, \", \")))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Profile \") + ui.Dim.Render(profile))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Language \") + ui.Dim.Render(language))\n\tif useLangSmith {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" LangSmith \") + ui.Green.Render(\"enabled\"))\n\t}\n\tfmt.Println(ui.Dim.Render(\" │\"))\n\tfmt.Println(ui.Dim.Render(\" │ \") + ui.Dim.Render(config.EnvPath()))\n\tfmt.Println(ui.Dim.Render(\" └──────────────────────────────────┘\"))\n\tfmt.Println()\n\tui.DimText(\" Run 'decepticon' to start the platform\")\n\treturn nil\n}\n\nfunc contains(haystack []string, needle string) bool {\n\treturn slices.Contains(haystack, needle)\n}\n\nfunc nonEmpty(s string) error {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn fmt.Errorf(\"value is required\")\n\t}\n\treturn nil\n}\n\nfunc boolStr(b bool) string {\n\tif b {\n\t\treturn \"true\"\n\t}\n\treturn \"false\"\n}\n\n// buildOllamaModelField returns the OLLAMA_MODEL form field that\n// matches the host probe outcome: strict Select when tool-capable\n// models are pulled, otherwise a remediation Note (different message\n// for the reachable-but-no-tools case vs unreachable Ollama).\nfunc buildOllamaModelField(probe ollamaProbeResult, selected *string) huh.Field {\n\tif len(probe.ToolCapableModels) > 0 {\n\t\toptions := make([]huh.Option[string], 0, len(probe.ToolCapableModels))\n\t\tfor _, m := range probe.ToolCapableModels {\n\t\t\toptions = append(options, huh.NewOption(m, m))\n\t\t}\n\t\tif !slices.Contains(probe.ToolCapableModels, *selected) {\n\t\t\t*selected = probe.ToolCapableModels[0]\n\t\t}\n\t\treturn huh.NewSelect[string]().\n\t\t\tTitle(\"OLLAMA_MODEL\").\n\t\t\tDescription(\"Tool-capable models found on your host. Decepticon\\nagents always emit tool calls — these are the only\\nmodels the wizard will accept.\").\n\t\t\tOptions(options...).\n\t\t\tValue(selected)\n\t}\n\n\tif probe.Reachable {\n\t\treturn huh.NewNote().\n\t\t\tTitle(\"OLLAMA_MODEL — no tool-capable models found\").\n\t\t\tDescription(\"Ollama is reachable but none of your pulled models\\nadvertise the 'tools' capability. Decepticon agents\\nalways emit tool calls, so a model without tool\\nsupport cannot power them.\\n\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n\t}\n\n\treturn huh.NewNote().\n\t\tTitle(\"OLLAMA_MODEL — Ollama not reachable\").\n\t\tDescription(\"Could not reach Ollama at \" + defaultOllamaAPIBase + \".\\n\\nMost likely Ollama isn't running or is bound to\\n127.0.0.1 only (which the Decepticon container can't\\nsee). Launch it on all interfaces, pull a tool-capable\\nmodel, then re-run the wizard:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n}\n\n// ollamaUnusableError surfaces the post-form remediation when the user\n// picked Ollama but the probe found nothing usable. Two flavors so the\n// hint matches whichever in-form Note was shown.\nfunc ollamaUnusableError(probe ollamaProbeResult, baseURL string) error {\n\tif probe.Reachable {\n\t\treturn fmt.Errorf(\n\t\t\t\"Ollama selected but no tool-capable models found on the host.\\n\" +\n\t\t\t\t\"Decepticon agents always emit tool calls — pull a tool-capable\\n\" +\n\t\t\t\t\"model and verify it advertises tools, then re-run:\\n\\n\" +\n\t\t\t\t\" ollama pull qwen3-coder:30b\\n\" +\n\t\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\" +\n\t\t\t\t\" decepticon onboard --reset\")\n\t}\n\treturn fmt.Errorf(\n\t\t\"Ollama selected but the host probe could not reach %s.\\n\"+\n\t\t\t\"Make sure Ollama is running and bound to all interfaces (the\\n\"+\n\t\t\t\"default 127.0.0.1 binding is invisible to containers), then\\n\"+\n\t\t\t\"pull a tool-capable model and re-run:\\n\\n\"+\n\t\t\t\" OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\"+\n\t\t\t\" ollama pull qwen3-coder:30b\\n\"+\n\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\"+\n\t\t\t\" decepticon onboard --reset\",\n\t\tbaseURL)\n}\n"}, "40609afe6aa18e80adff380d246a2bde775441a0": {"t": 1785755408.8530772, "n": 23618, "h": "73cecf94a77935af", "c": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/huh/v2\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui\"\n\t\"github.com/spf13/cobra\"\n)\n\n// onboardOllamaProbeBudget bounds the wait for the background Ollama\n// discovery probe at form-construction time. Per-request timeout\n// (ollama_models.go) is much shorter; this is the worst-case across\n// all candidate URLs.\nconst onboardOllamaProbeBudget = 3 * time.Second\n\nvar resetFlag bool\n\nvar onboardCmd = &cobra.Command{\n\tUse: \"onboard\",\n\tShort: \"Configure Decepticon (auth methods, model profile, observability)\",\n\tRunE: runOnboard,\n}\n\nfunc init() {\n\tonboardCmd.Flags().BoolVar(&resetFlag, \"reset\", false, \"Reconfigure even if .env already exists\")\n\trootCmd.AddCommand(onboardCmd)\n}\n\n// AuthMethod identifiers — must match decepticon/llm/models.py::AuthMethod.\nconst (\n\tmethodAnthropicOAuth = \"anthropic_oauth\"\n\tmethodAnthropicAPI = \"anthropic_api\"\n\tmethodOpenAIOAuth = \"openai_oauth\"\n\tmethodOpenAIAPI = \"openai_api\"\n\tmethodGoogleOAuth = \"google_oauth\"\n\tmethodGoogleAPI = \"google_api\"\n\tmethodMiniMaxAPI = \"minimax_api\"\n\tmethodDeepSeekAPI = \"deepseek_api\"\n\tmethodXAIAPI = \"xai_api\"\n\tmethodGrokOAuth = \"grok_oauth\"\n\tmethodMistralAPI = \"mistral_api\"\n\tmethodOpenRouterAPI = \"openrouter_api\"\n\tmethodNvidiaAPI = \"nvidia_api\"\n\tmethodCopilotOAuth = \"copilot_oauth\"\n\tmethodPerplexityOAuth = \"perplexity_oauth\"\n\tmethodOllamaLocal = \"ollama_local\"\n)\n\n// Default Ollama wiring shown to OSS users. ``host.docker.internal``\n// is the universal answer regardless of where Ollama runs (macOS host,\n// WSL2 distro, native Linux): from inside Decepticon's containers it\n// resolves to the host network namespace via the\n// ``extra_hosts: [host.docker.internal:host-gateway]`` entry on the\n// litellm service in docker-compose.yml. ``localhost`` is never the\n// right answer here — that's the container itself.\n//\n// The host's Ollama must additionally be bound to 0.0.0.0 (the default\n// 127.0.0.1 binding only accepts host-side connections); the wizard\n// surfaces this requirement to the user.\n//\n// The default model is the smallest/fastest one most laptops can\n// actually run; users with a GPU will pick something like qwen3-coder:30b.\nconst (\n\tdefaultOllamaAPIBase = \"http://host.docker.internal:11434\"\n\tdefaultOllamaModel = \"llama3.2\"\n)\n\n// methodOrder is the priority order surfaced in the wizard. The\n// resulting DECEPTICON_AUTH_PRIORITY preserves this order, filtered\n// to the methods the user actually selected. OAuth precedes the\n// matching API on purpose: a subscription primary should fall back\n// to the paid API only when the subscription quota is exhausted.\n// Ollama sits last: cloud providers are usually preferred when both\n// are configured, but a user wanting to lead with local-only inference\n// can reorder the priority manually in .env.\nvar methodOrder = []string{\n\tmethodAnthropicOAuth,\n\tmethodAnthropicAPI,\n\tmethodOpenAIOAuth,\n\tmethodOpenAIAPI,\n\tmethodGoogleOAuth,\n\tmethodGoogleAPI,\n\tmethodMiniMaxAPI,\n\tmethodDeepSeekAPI,\n\tmethodXAIAPI,\n\tmethodGrokOAuth,\n\tmethodMistralAPI,\n\tmethodOpenRouterAPI,\n\tmethodNvidiaAPI,\n\tmethodCopilotOAuth,\n\tmethodPerplexityOAuth,\n\tmethodOllamaLocal,\n}\n\nfunc runOnboard(cmd *cobra.Command, args []string) error {\n\tif config.EnvExists() && !resetFlag {\n\t\tui.Info(\".env already configured at \" + config.EnvPath())\n\t\tui.DimText(\"Run 'decepticon onboard --reset' to reconfigure\")\n\t\treturn nil\n\t}\n\n\t// Kick off Ollama discovery in the background so the network\n\t// round-trip overlaps with huh's startup work; the OLLAMA_MODEL\n\t// field type depends on the result (Select vs remediation Note).\n\tprobeCh := make(chan ollamaProbeResult, 1)\n\tgo func() {\n\t\tprobeCh <- probeOllamaForOnboard(defaultOllamaAPIBase)\n\t}()\n\n\tvar (\n\t\tmethods []string\n\t\tanthropicKey string\n\t\topenaiKey string\n\t\tgeminiKey string\n\t\tminimaxKey string\n\t\tdeepseekKey string\n\t\txaiKey string\n\t\tmistralKey string\n\t\topenrouterKey string\n\t\tnvidiaKey string\n\t\tchatgptSessionToken string\n\t\tgeminiSessionCookies string\n\t\tcopilotRefreshToken string\n\t\tgrokSessionToken string\n\t\tperplexitySessionToken string\n\t\tollamaAPIBase = defaultOllamaAPIBase\n\t\tollamaModel = defaultOllamaModel\n\t\tprofile string\n\t\tuseLangSmith bool\n\t\tlangSmithKey string\n\t)\n\n\t// Block on the probe (zero-value result on timeout means\n\t// \"unreachable\" — drops through to the remediation Note).\n\t// time.NewTimer + Stop avoids the time.After timer leak when the\n\t// probe finishes first.\n\tprobeTimer := time.NewTimer(onboardOllamaProbeBudget)\n\tvar ollamaProbe ollamaProbeResult\n\tselect {\n\tcase ollamaProbe = <-probeCh:\n\t\tprobeTimer.Stop()\n\tcase <-probeTimer.C:\n\t}\n\tollamaModelField := buildOllamaModelField(ollamaProbe, &ollamaModel)\n\n\tform := huh.NewForm(\n\t\t// Intro\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Decepticon Setup\").\n\t\t\t\tDescription(\"Configure auth methods, model profile, and\\nobservability.\\n\\nUse ↑↓ to navigate, space to toggle, Enter to confirm.\"),\n\t\t),\n\n\t\t// Step 1: Auth methods (multi-select)\n\t\thuh.NewGroup(\n\t\t\thuh.NewMultiSelect[string]().\n\t\t\t\tTitle(\"Auth Methods\").\n\t\t\t\tDescription(\"Pick every credential you have. Each method is an\\nindependent fallback in priority order shown.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"Claude Code OAuth — Anthropic subscription (auth/*)\", methodAnthropicOAuth),\n\t\t\t\t\thuh.NewOption(\"Anthropic API Key — sk-ant-...\", methodAnthropicAPI),\n\t\t\t\t\thuh.NewOption(\"ChatGPT OAuth — ChatGPT Pro/Plus/Team subscription (auth/gpt-*)\", methodOpenAIOAuth),\n\t\t\t\t\thuh.NewOption(\"OpenAI API Key — sk-...\", methodOpenAIAPI),\n\t\t\t\t\thuh.NewOption(\"Google API Key — AIza... (Gemini)\", methodGoogleAPI),\n\t\t\t\t\thuh.NewOption(\"MiniMax API Key — eyJ...\", methodMiniMaxAPI),\n\t\t\t\t\thuh.NewOption(\"DeepSeek API Key — sk-...\", methodDeepSeekAPI),\n\t\t\t\t\thuh.NewOption(\"xAI API Key — xai-... (Grok)\", methodXAIAPI),\n\t\t\t\t\thuh.NewOption(\"Mistral API Key — (no fixed prefix)\", methodMistralAPI),\n\t\t\t\t\thuh.NewOption(\"OpenRouter API Key — sk-or-...\", methodOpenRouterAPI),\n\t\t\t\t\thuh.NewOption(\"Nvidia NIM API Key — nvapi-...\", methodNvidiaAPI),\n\t\t\t\t\thuh.NewOption(\"Gemini Advanced — Google One AI Premium subscription (gemini-sub/*)\", methodGoogleOAuth),\n\t\t\t\t\thuh.NewOption(\"SuperGrok — X Premium+ Grok subscription (grok-sub/*)\", methodGrokOAuth),\n\t\t\t\t\thuh.NewOption(\"GitHub Copilot Pro — Copilot subscription (copilot/*)\", methodCopilotOAuth),\n\t\t\t\t\thuh.NewOption(\"Perplexity Pro — Perplexity subscription (pplx-sub/*)\", methodPerplexityOAuth),\n\t\t\t\t\thuh.NewOption(\"Local LLM (Ollama) — any pulled model, no API key\", methodOllamaLocal),\n\t\t\t\t).\n\t\t\t\tValue(&methods).\n\t\t\t\tValidate(func(s []string) error {\n\t\t\t\t\tif len(s) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"select at least one credential\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"1 / 4 · Credentials\").\n\t\t\tDescription(\"Select all that apply\"),\n\n\t\t// Step 2a: Anthropic API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Anthropic API Key\").\n\t\t\t\tPlaceholder(\"sk-ant-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&anthropicKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Anthropic API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicAPI) }),\n\n\t\t// Step 2b: ChatGPT subscription session token\n\t\t// ChatGPT OAuth has no equivalent of `claude /login` to provision\n\t\t// tokens automatically, so we ask the user to paste the\n\t\t// `__Secure-next-auth.session-token` cookie from a signed-in\n\t\t// chatgpt.com browser session. Optional — users who set\n\t\t// CHATGPT_ACCESS_TOKEN externally or place ~/.config/chatgpt/tokens.json\n\t\t// can leave this blank and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"ChatGPT Session Token\").\n\t\t\t\tDescription(\"Open chatgpt.com → DevTools → Application →\\nCookies → chatgpt.com → copy the value of\\n`__Secure-next-auth.session-token`. Or leave\\nblank to use CHATGPT_ACCESS_TOKEN / tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CHATGPT_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"eyJhbGciOiJ... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&chatgptSessionToken),\n\t\t).Title(\"2 / 4 · ChatGPT OAuth\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIOAuth) }),\n\n\t\t// Step 2c: OpenAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenAI API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · OpenAI API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIAPI) }),\n\n\t\t// Step 2c: Google API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Google (Gemini) API Key\").\n\t\t\t\tPlaceholder(\"AIza...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Google API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleAPI) }),\n\n\t\t// Step 2d: MiniMax API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MiniMax API Key\").\n\t\t\t\tPlaceholder(\"eyJ...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&minimaxKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · MiniMax API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMiniMaxAPI) }),\n\n\t\t// Step 2d-i: DeepSeek API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DeepSeek API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&deepseekKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · DeepSeek API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDeepSeekAPI) }),\n\n\t\t// Step 2d-ii: xAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"xAI API Key\").\n\t\t\t\tPlaceholder(\"xai-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&xaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · xAI API (Grok)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodXAIAPI) }),\n\n\t\t// Step 2d-iii: Mistral API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Mistral API Key\").\n\t\t\t\tPlaceholder(\"paste your Mistral API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&mistralKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Mistral API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMistralAPI) }),\n\n\t\t// Step 2e: OpenRouter API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenRouter API Key\").\n\t\t\t\tPlaceholder(\"sk-or-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openrouterKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · OpenRouter API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenRouterAPI) }),\n\n\t\t// Step 2f: Nvidia NIM API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Nvidia NIM API Key\").\n\t\t\t\tPlaceholder(\"nvapi-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&nvidiaKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Nvidia NIM API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodNvidiaAPI) }),\n\n\t\t// Step 2-oauth-i: Gemini Advanced subscription\n\t\t// Multi-cookie value (NID + Secure-1PSID + Secure-1PSIDTS, etc.)\n\t\t// joined with semicolons. Optional — power users can drop a\n\t\t// tokens.json under ~/.config/gemini/ instead and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Gemini Advanced Subscription\").\n\t\t\t\tDescription(\"Open gemini.google.com → DevTools → Application →\\nCookies → gemini.google.com. Copy the values of\\nNID, __Secure-1PSID, __Secure-1PSIDTS as a single\\nsemicolon-joined string. Or leave blank to use\\nGEMINI_ACCESS_TOKEN / ~/.config/gemini/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GEMINI_SESSION_COOKIES\").\n\t\t\t\tPlaceholder(\"NID=...; __Secure-1PSID=... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiSessionCookies),\n\t\t).Title(\"2 / 4 · Gemini Advanced\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleOAuth) }),\n\n\t\t// Step 2-oauth-ii: SuperGrok subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"SuperGrok Subscription\").\n\t\t\t\tDescription(\"Open grok.com → DevTools → Application →\\nCookies → grok.com → copy the value of\\n`auth_token`. Or leave blank to use\\nGROK_ACCESS_TOKEN / ~/.config/grok/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROK_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste auth_token cookie value (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&grokSessionToken),\n\t\t).Title(\"2 / 4 · SuperGrok\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGrokOAuth) }),\n\n\t\t// Step 2-oauth-iii: GitHub Copilot Pro subscription\n\t\t// Refresh token rotation, not a single session cookie. Devs can\n\t\t// extract one from VSCode's Copilot extension config or follow\n\t\t// gh-copilot-cli onboarding. Optional via tokens.json fallback.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GitHub Copilot Pro Subscription\").\n\t\t\t\tDescription(\"Provide a Copilot refresh token (long-lived, used\\nto rotate access tokens). Or leave blank to use\\nCOPILOT_ACCESS_TOKEN / ~/.config/copilot/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COPILOT_REFRESH_TOKEN\").\n\t\t\t\tPlaceholder(\"ghu_... or ghr_... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&copilotRefreshToken),\n\t\t).Title(\"2 / 4 · Copilot Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCopilotOAuth) }),\n\n\t\t// Step 2-oauth-iv: Perplexity Pro subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Perplexity Pro Subscription\").\n\t\t\t\tDescription(\"Open perplexity.ai → DevTools → Application →\\nCookies → perplexity.ai → copy the value of\\n`next-auth.session-token`. Or leave blank to use\\nPERPLEXITY_ACCESS_TOKEN / ~/.config/perplexity/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"PERPLEXITY_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste next-auth.session-token (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&perplexitySessionToken),\n\t\t).Title(\"2 / 4 · Perplexity Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodPerplexityOAuth) }),\n\n\t\t// Step 2g: Local Ollama. The OLLAMA_MODEL field is built from\n\t\t// the host probe (buildOllamaModelField): a strict Select when\n\t\t// tool-capable models are pulled, a remediation Note otherwise.\n\t\t// The post-form gate refuses to write .env in the Note cases.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Local Ollama\").\n\t\t\t\tDescription(\"Ollama must already be running on your host with\\nat least one tool-capable model pulled. Launch it\\nbound to all interfaces so the Decepticon container\\ncan reach it:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\\nThe default 127.0.0.1 binding only accepts host-side\\nconnections — containers won't see it.\\n\\nThe default URL `host.docker.internal:11434` works\\non macOS, Linux, and WSL2 (with or without Docker\\nDesktop). Only change it for remote / custom setups.\\nNote: the model list is probed against the default URL\\nat wizard start; if you customize OLLAMA_API_BASE,\\nfinish the wizard then re-run 'decepticon onboard\\n--reset' so the probe targets the new endpoint.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_API_BASE\").\n\t\t\t\tPlaceholder(defaultOllamaAPIBase).\n\t\t\t\tValue(&ollamaAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\tollamaModelField,\n\t\t).Title(\"2 / 4 · Local LLM (Ollama)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaLocal) }),\n\n\t\t// Step 3: Model profile\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Model Profile\").\n\t\t\t\tDescription(\"eco per-agent tier (recommended)\\nmax every agent on HIGH (expensive)\\ntest every agent on LOW (development)\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"eco — per-agent tier (recommended)\", \"eco\"),\n\t\t\t\t\thuh.NewOption(\"max — every agent on HIGH (expensive)\", \"max\"),\n\t\t\t\t\thuh.NewOption(\"test — every agent on LOW (development)\", \"test\"),\n\t\t\t\t).\n\t\t\t\tValue(&profile),\n\t\t).Title(\"3 / 4 · Profile\"),\n\n\t\t// Step 4a: LangSmith toggle\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Enable LangSmith?\").\n\t\t\t\tDescription(\"LLM observability and trace collection\").\n\t\t\t\tAffirmative(\"Yes\").\n\t\t\t\tNegative(\"No\").\n\t\t\t\tValue(&useLangSmith),\n\t\t).Title(\"4 / 4 · Observability\"),\n\n\t\t// Step 4b: LangSmith key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LangSmith API Key\").\n\t\t\t\tPlaceholder(\"lsv2_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&langSmithKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"4 / 4 · LangSmith\").\n\t\t\tWithHideFunc(func() bool { return !useLangSmith }),\n\t).WithTheme(huh.ThemeFunc(ui.DecepticonTheme))\n\n\tif err := form.Run(); err != nil {\n\t\treturn fmt.Errorf(\"setup cancelled: %w\", err)\n\t}\n\n\t// Strict-mode gate: refuse to write .env when the user picked\n\t// Ollama but the host probe found no tool-capable model. The\n\t// in-form Note shows the same remediation; this is the boundary\n\t// guarantee that a broken setup never ships.\n\tif contains(methods, methodOllamaLocal) && len(ollamaProbe.ToolCapableModels) == 0 {\n\t\treturn ollamaUnusableError(ollamaProbe, ollamaAPIBase)\n\t}\n\n\t// huh.MultiSelect returns selected values in option order, not the\n\t// order the user toggled. Re-derive the priority by walking\n\t// methodOrder and keeping only what the user picked.\n\tpriority := make([]string, 0, len(methods))\n\tfor _, m := range methodOrder {\n\t\tif contains(methods, m) {\n\t\t\tpriority = append(priority, m)\n\t\t}\n\t}\n\n\tvalues := map[string]string{\n\t\t\"DECEPTICON_MODEL_PROFILE\": profile,\n\t\t\"DECEPTICON_AUTH_PRIORITY\": strings.Join(priority, \",\"),\n\t\t\"DECEPTICON_AUTH_CLAUDE_CODE\": boolStr(contains(methods, methodAnthropicOAuth)),\n\t\t\"DECEPTICON_AUTH_CHATGPT\": boolStr(contains(methods, methodOpenAIOAuth)),\n\t\t\"DECEPTICON_AUTH_GEMINI\": boolStr(contains(methods, methodGoogleOAuth)),\n\t\t\"DECEPTICON_AUTH_GROK\": boolStr(contains(methods, methodGrokOAuth)),\n\t\t\"DECEPTICON_AUTH_COPILOT\": boolStr(contains(methods, methodCopilotOAuth)),\n\t\t\"DECEPTICON_AUTH_PERPLEXITY\": boolStr(contains(methods, methodPerplexityOAuth)),\n\t}\n\n\tif anthropicKey != \"\" {\n\t\tvalues[\"ANTHROPIC_API_KEY\"] = anthropicKey\n\t}\n\tif openaiKey != \"\" {\n\t\tvalues[\"OPENAI_API_KEY\"] = openaiKey\n\t}\n\tif geminiKey != \"\" {\n\t\tvalues[\"GEMINI_API_KEY\"] = geminiKey\n\t}\n\tif minimaxKey != \"\" {\n\t\tvalues[\"MINIMAX_API_KEY\"] = minimaxKey\n\t}\n\tif deepseekKey != \"\" {\n\t\tvalues[\"DEEPSEEK_API_KEY\"] = deepseekKey\n\t}\n\tif xaiKey != \"\" {\n\t\tvalues[\"XAI_API_KEY\"] = xaiKey\n\t}\n\tif mistralKey != \"\" {\n\t\tvalues[\"MISTRAL_API_KEY\"] = mistralKey\n\t}\n\tif openrouterKey != \"\" {\n\t\tvalues[\"OPENROUTER_API_KEY\"] = openrouterKey\n\t}\n\tif nvidiaKey != \"\" {\n\t\tvalues[\"NVIDIA_API_KEY\"] = nvidiaKey\n\t}\n\tif chatgptSessionToken != \"\" {\n\t\tvalues[\"CHATGPT_SESSION_TOKEN\"] = chatgptSessionToken\n\t}\n\tif geminiSessionCookies != \"\" {\n\t\tvalues[\"GEMINI_SESSION_COOKIES\"] = geminiSessionCookies\n\t}\n\tif grokSessionToken != \"\" {\n\t\tvalues[\"GROK_SESSION_TOKEN\"] = grokSessionToken\n\t}\n\tif copilotRefreshToken != \"\" {\n\t\tvalues[\"COPILOT_REFRESH_TOKEN\"] = copilotRefreshToken\n\t}\n\tif perplexitySessionToken != \"\" {\n\t\tvalues[\"PERPLEXITY_SESSION_TOKEN\"] = perplexitySessionToken\n\t}\n\tif contains(methods, methodOllamaLocal) {\n\t\tvalues[\"OLLAMA_API_BASE\"] = strings.TrimSpace(ollamaAPIBase)\n\t\tvalues[\"OLLAMA_MODEL\"] = strings.TrimSpace(ollamaModel)\n\t}\n\n\tif useLangSmith && langSmithKey != \"\" {\n\t\tvalues[\"LANGSMITH_TRACING\"] = \"true\"\n\t\tvalues[\"LANGSMITH_API_KEY\"] = langSmithKey\n\t\tvalues[\"LANGSMITH_PROJECT\"] = \"decepticon\"\n\t}\n\n\tif err := config.WriteEnvFromEmbed(config.EnvPath(), values); err != nil {\n\t\treturn fmt.Errorf(\"write .env: %w\", err)\n\t}\n\n\t// Summary\n\tfmt.Println()\n\tfmt.Println(ui.Green.Render(\" ✓ Configuration saved\"))\n\tfmt.Println()\n\tfmt.Println(ui.Dim.Render(\" ┌──────────────────────────────────┐\"))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Methods \") + ui.Dim.Render(strings.Join(priority, \", \")))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Profile \") + ui.Dim.Render(profile))\n\tif useLangSmith {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" LangSmith \") + ui.Green.Render(\"enabled\"))\n\t}\n\tfmt.Println(ui.Dim.Render(\" │\"))\n\tfmt.Println(ui.Dim.Render(\" │ \") + ui.Dim.Render(config.EnvPath()))\n\tfmt.Println(ui.Dim.Render(\" └──────────────────────────────────┘\"))\n\tfmt.Println()\n\tui.DimText(\" Run 'decepticon' to start the platform\")\n\treturn nil\n}\n\nfunc contains(haystack []string, needle string) bool {\n\treturn slices.Contains(haystack, needle)\n}\n\nfunc nonEmpty(s string) error {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn fmt.Errorf(\"value is required\")\n\t}\n\treturn nil\n}\n\nfunc boolStr(b bool) string {\n\tif b {\n\t\treturn \"true\"\n\t}\n\treturn \"false\"\n}\n\n// buildOllamaModelField returns the OLLAMA_MODEL form field that\n// matches the host probe outcome: strict Select when tool-capable\n// models are pulled, otherwise a remediation Note (different message\n// for the reachable-but-no-tools case vs unreachable Ollama).\nfunc buildOllamaModelField(probe ollamaProbeResult, selected *string) huh.Field {\n\tif len(probe.ToolCapableModels) > 0 {\n\t\toptions := make([]huh.Option[string], 0, len(probe.ToolCapableModels))\n\t\tfor _, m := range probe.ToolCapableModels {\n\t\t\toptions = append(options, huh.NewOption(m, m))\n\t\t}\n\t\tif !slices.Contains(probe.ToolCapableModels, *selected) {\n\t\t\t*selected = probe.ToolCapableModels[0]\n\t\t}\n\t\treturn huh.NewSelect[string]().\n\t\t\tTitle(\"OLLAMA_MODEL\").\n\t\t\tDescription(\"Tool-capable models found on your host. Decepticon\\nagents always emit tool calls — these are the only\\nmodels the wizard will accept.\").\n\t\t\tOptions(options...).\n\t\t\tValue(selected)\n\t}\n\n\tif probe.Reachable {\n\t\treturn huh.NewNote().\n\t\t\tTitle(\"OLLAMA_MODEL — no tool-capable models found\").\n\t\t\tDescription(\"Ollama is reachable but none of your pulled models\\nadvertise the 'tools' capability. Decepticon agents\\nalways emit tool calls, so a model without tool\\nsupport cannot power them.\\n\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n\t}\n\n\treturn huh.NewNote().\n\t\tTitle(\"OLLAMA_MODEL — Ollama not reachable\").\n\t\tDescription(\"Could not reach Ollama at \" + defaultOllamaAPIBase + \".\\n\\nMost likely Ollama isn't running or is bound to\\n127.0.0.1 only (which the Decepticon container can't\\nsee). Launch it on all interfaces, pull a tool-capable\\nmodel, then re-run the wizard:\\n\\n OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n ollama pull qwen3-coder:30b\\n ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\\nThen press Esc and re-run 'decepticon onboard'.\")\n}\n\n// ollamaUnusableError surfaces the post-form remediation when the user\n// picked Ollama but the probe found nothing usable. Two flavors so the\n// hint matches whichever in-form Note was shown.\nfunc ollamaUnusableError(probe ollamaProbeResult, baseURL string) error {\n\tif probe.Reachable {\n\t\treturn fmt.Errorf(\n\t\t\t\"Ollama selected but no tool-capable models found on the host.\\n\" +\n\t\t\t\t\"Decepticon agents always emit tool calls — pull a tool-capable\\n\" +\n\t\t\t\t\"model and verify it advertises tools, then re-run:\\n\\n\" +\n\t\t\t\t\" ollama pull qwen3-coder:30b\\n\" +\n\t\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\" +\n\t\t\t\t\" decepticon onboard --reset\")\n\t}\n\treturn fmt.Errorf(\n\t\t\"Ollama selected but the host probe could not reach %s.\\n\"+\n\t\t\t\"Make sure Ollama is running and bound to all interfaces (the\\n\"+\n\t\t\t\"default 127.0.0.1 binding is invisible to containers), then\\n\"+\n\t\t\t\"pull a tool-capable model and re-run:\\n\\n\"+\n\t\t\t\" OLLAMA_HOST=0.0.0.0:11434 ollama serve\\n\"+\n\t\t\t\" ollama pull qwen3-coder:30b\\n\"+\n\t\t\t\" ollama show qwen3-coder:30b # capabilities should list 'tools'\\n\"+\n\t\t\t\" decepticon onboard --reset\",\n\t\tbaseURL)\n}\n"}, "5d13397c069e6463aa2a1aff1f4ad4cb64f1bba7": {"t": 1785755410.3729968, "n": 18996, "h": "d55b29bcee4596bf", "c": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"charm.land/huh/v2\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar resetFlag bool\n\nvar onboardCmd = &cobra.Command{\n\tUse: \"onboard\",\n\tShort: \"Configure Decepticon (auth methods, model profile, observability)\",\n\tRunE: runOnboard,\n}\n\nfunc init() {\n\tonboardCmd.Flags().BoolVar(&resetFlag, \"reset\", false, \"Reconfigure even if .env already exists\")\n\trootCmd.AddCommand(onboardCmd)\n}\n\n// AuthMethod identifiers — must match decepticon/llm/models.py::AuthMethod.\nconst (\n\tmethodAnthropicOAuth = \"anthropic_oauth\"\n\tmethodAnthropicAPI = \"anthropic_api\"\n\tmethodOpenAIOAuth = \"openai_oauth\"\n\tmethodOpenAIAPI = \"openai_api\"\n\tmethodGoogleOAuth = \"google_oauth\"\n\tmethodGoogleAPI = \"google_api\"\n\tmethodMiniMaxAPI = \"minimax_api\"\n\tmethodDeepSeekAPI = \"deepseek_api\"\n\tmethodXAIAPI = \"xai_api\"\n\tmethodGrokOAuth = \"grok_oauth\"\n\tmethodMistralAPI = \"mistral_api\"\n\tmethodOpenRouterAPI = \"openrouter_api\"\n\tmethodNvidiaAPI = \"nvidia_api\"\n\tmethodCopilotOAuth = \"copilot_oauth\"\n\tmethodPerplexityOAuth = \"perplexity_oauth\"\n\tmethodOllamaLocal = \"ollama_local\"\n)\n\n// Default Ollama wiring shown to OSS users. host.docker.internal works\n// out of the box on macOS, Docker Desktop on Windows/WSL2, and on\n// Linux thanks to the ``extra_hosts: [host.docker.internal:host-gateway]``\n// entry on the litellm service in docker-compose.yml. WSL2 users who\n// installed Ollama *inside* the WSL distro itself (rather than on the\n// Windows host) need to override this with http://localhost:11434.\n// The default model is the smallest/fastest one most laptops can\n// actually run; users with a GPU will pick something like qwen3-coder:30b.\nconst (\n\tdefaultOllamaAPIBase = \"http://host.docker.internal:11434\"\n\tdefaultOllamaModel = \"llama3.2\"\n)\n\n// methodOrder is the priority order surfaced in the wizard. The\n// resulting DECEPTICON_AUTH_PRIORITY preserves this order, filtered\n// to the methods the user actually selected. OAuth precedes the\n// matching API on purpose: a subscription primary should fall back\n// to the paid API only when the subscription quota is exhausted.\n// Ollama sits last: cloud providers are usually preferred when both\n// are configured, but a user wanting to lead with local-only inference\n// can reorder the priority manually in .env.\nvar methodOrder = []string{\n\tmethodAnthropicOAuth,\n\tmethodAnthropicAPI,\n\tmethodOpenAIOAuth,\n\tmethodOpenAIAPI,\n\tmethodGoogleOAuth,\n\tmethodGoogleAPI,\n\tmethodMiniMaxAPI,\n\tmethodDeepSeekAPI,\n\tmethodXAIAPI,\n\tmethodGrokOAuth,\n\tmethodMistralAPI,\n\tmethodOpenRouterAPI,\n\tmethodNvidiaAPI,\n\tmethodCopilotOAuth,\n\tmethodPerplexityOAuth,\n\tmethodOllamaLocal,\n}\n\nfunc runOnboard(cmd *cobra.Command, args []string) error {\n\tif config.EnvExists() && !resetFlag {\n\t\tui.Info(\".env already configured at \" + config.EnvPath())\n\t\tui.DimText(\"Run 'decepticon onboard --reset' to reconfigure\")\n\t\treturn nil\n\t}\n\n\tvar (\n\t\tmethods []string\n\t\tanthropicKey string\n\t\topenaiKey string\n\t\tgeminiKey string\n\t\tminimaxKey string\n\t\tdeepseekKey string\n\t\txaiKey string\n\t\tmistralKey string\n\t\topenrouterKey string\n\t\tnvidiaKey string\n\t\tchatgptSessionToken string\n\t\tgeminiSessionCookies string\n\t\tcopilotRefreshToken string\n\t\tgrokSessionToken string\n\t\tperplexitySessionToken string\n\t\tollamaAPIBase = defaultOllamaAPIBase\n\t\tollamaModel = defaultOllamaModel\n\t\tprofile string\n\t\tuseLangSmith bool\n\t\tlangSmithKey string\n\t)\n\n\tform := huh.NewForm(\n\t\t// Intro\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Decepticon Setup\").\n\t\t\t\tDescription(\"Configure auth methods, model profile, and\\nobservability.\\n\\nUse ↑↓ to navigate, space to toggle, Enter to confirm.\"),\n\t\t),\n\n\t\t// Step 1: Auth methods (multi-select)\n\t\thuh.NewGroup(\n\t\t\thuh.NewMultiSelect[string]().\n\t\t\t\tTitle(\"Auth Methods\").\n\t\t\t\tDescription(\"Pick every credential you have. Each method is an\\nindependent fallback in priority order shown.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"Claude Code OAuth — Anthropic subscription (auth/*)\", methodAnthropicOAuth),\n\t\t\t\t\thuh.NewOption(\"Anthropic API Key — sk-ant-...\", methodAnthropicAPI),\n\t\t\t\t\thuh.NewOption(\"ChatGPT OAuth — ChatGPT Pro/Plus/Team subscription (auth/gpt-*)\", methodOpenAIOAuth),\n\t\t\t\t\thuh.NewOption(\"OpenAI API Key — sk-...\", methodOpenAIAPI),\n\t\t\t\t\thuh.NewOption(\"Google API Key — AIza... (Gemini)\", methodGoogleAPI),\n\t\t\t\t\thuh.NewOption(\"MiniMax API Key — eyJ...\", methodMiniMaxAPI),\n\t\t\t\t\thuh.NewOption(\"DeepSeek API Key — sk-...\", methodDeepSeekAPI),\n\t\t\t\t\thuh.NewOption(\"xAI API Key — xai-... (Grok)\", methodXAIAPI),\n\t\t\t\t\thuh.NewOption(\"Mistral API Key — (no fixed prefix)\", methodMistralAPI),\n\t\t\t\t\thuh.NewOption(\"OpenRouter API Key — sk-or-...\", methodOpenRouterAPI),\n\t\t\t\t\thuh.NewOption(\"Nvidia NIM API Key — nvapi-...\", methodNvidiaAPI),\n\t\t\t\t\thuh.NewOption(\"Gemini Advanced — Google One AI Premium subscription (gemini-sub/*)\", methodGoogleOAuth),\n\t\t\t\t\thuh.NewOption(\"SuperGrok — X Premium+ Grok subscription (grok-sub/*)\", methodGrokOAuth),\n\t\t\t\t\thuh.NewOption(\"GitHub Copilot Pro — Copilot subscription (copilot/*)\", methodCopilotOAuth),\n\t\t\t\t\thuh.NewOption(\"Perplexity Pro — Perplexity subscription (pplx-sub/*)\", methodPerplexityOAuth),\n\t\t\t\t\thuh.NewOption(\"Local LLM (Ollama) — any pulled model, no API key\", methodOllamaLocal),\n\t\t\t\t).\n\t\t\t\tValue(&methods).\n\t\t\t\tValidate(func(s []string) error {\n\t\t\t\t\tif len(s) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"select at least one credential\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"1 / 4 · Credentials\").\n\t\t\tDescription(\"Select all that apply\"),\n\n\t\t// Step 2a: Anthropic API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Anthropic API Key\").\n\t\t\t\tPlaceholder(\"sk-ant-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&anthropicKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Anthropic API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicAPI) }),\n\n\t\t// Step 2b: ChatGPT subscription session token\n\t\t// ChatGPT OAuth has no equivalent of `claude /login` to provision\n\t\t// tokens automatically, so we ask the user to paste the\n\t\t// `__Secure-next-auth.session-token` cookie from a signed-in\n\t\t// chatgpt.com browser session. Optional — users who set\n\t\t// CHATGPT_ACCESS_TOKEN externally or place ~/.config/chatgpt/tokens.json\n\t\t// can leave this blank and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"ChatGPT Session Token\").\n\t\t\t\tDescription(\"Open chatgpt.com → DevTools → Application →\\nCookies → chatgpt.com → copy the value of\\n`__Secure-next-auth.session-token`. Or leave\\nblank to use CHATGPT_ACCESS_TOKEN / tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CHATGPT_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"eyJhbGciOiJ... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&chatgptSessionToken),\n\t\t).Title(\"2 / 4 · ChatGPT OAuth\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIOAuth) }),\n\n\t\t// Step 2c: OpenAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenAI API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · OpenAI API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIAPI) }),\n\n\t\t// Step 2c: Google API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Google (Gemini) API Key\").\n\t\t\t\tPlaceholder(\"AIza...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Google API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleAPI) }),\n\n\t\t// Step 2d: MiniMax API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MiniMax API Key\").\n\t\t\t\tPlaceholder(\"eyJ...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&minimaxKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · MiniMax API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMiniMaxAPI) }),\n\n\t\t// Step 2d-i: DeepSeek API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DeepSeek API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&deepseekKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · DeepSeek API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDeepSeekAPI) }),\n\n\t\t// Step 2d-ii: xAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"xAI API Key\").\n\t\t\t\tPlaceholder(\"xai-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&xaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · xAI API (Grok)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodXAIAPI) }),\n\n\t\t// Step 2d-iii: Mistral API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Mistral API Key\").\n\t\t\t\tPlaceholder(\"paste your Mistral API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&mistralKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Mistral API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMistralAPI) }),\n\n\t\t// Step 2e: OpenRouter API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenRouter API Key\").\n\t\t\t\tPlaceholder(\"sk-or-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openrouterKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · OpenRouter API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenRouterAPI) }),\n\n\t\t// Step 2f: Nvidia NIM API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Nvidia NIM API Key\").\n\t\t\t\tPlaceholder(\"nvapi-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&nvidiaKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Nvidia NIM API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodNvidiaAPI) }),\n\n\t\t// Step 2-oauth-i: Gemini Advanced subscription\n\t\t// Multi-cookie value (NID + Secure-1PSID + Secure-1PSIDTS, etc.)\n\t\t// joined with semicolons. Optional — power users can drop a\n\t\t// tokens.json under ~/.config/gemini/ instead and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Gemini Advanced Subscription\").\n\t\t\t\tDescription(\"Open gemini.google.com → DevTools → Application →\\nCookies → gemini.google.com. Copy the values of\\nNID, __Secure-1PSID, __Secure-1PSIDTS as a single\\nsemicolon-joined string. Or leave blank to use\\nGEMINI_ACCESS_TOKEN / ~/.config/gemini/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GEMINI_SESSION_COOKIES\").\n\t\t\t\tPlaceholder(\"NID=...; __Secure-1PSID=... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiSessionCookies),\n\t\t).Title(\"2 / 4 · Gemini Advanced\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleOAuth) }),\n\n\t\t// Step 2-oauth-ii: SuperGrok subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"SuperGrok Subscription\").\n\t\t\t\tDescription(\"Open grok.com → DevTools → Application →\\nCookies → grok.com → copy the value of\\n`auth_token`. Or leave blank to use\\nGROK_ACCESS_TOKEN / ~/.config/grok/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROK_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste auth_token cookie value (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&grokSessionToken),\n\t\t).Title(\"2 / 4 · SuperGrok\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGrokOAuth) }),\n\n\t\t// Step 2-oauth-iii: GitHub Copilot Pro subscription\n\t\t// Refresh token rotation, not a single session cookie. Devs can\n\t\t// extract one from VSCode's Copilot extension config or follow\n\t\t// gh-copilot-cli onboarding. Optional via tokens.json fallback.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GitHub Copilot Pro Subscription\").\n\t\t\t\tDescription(\"Provide a Copilot refresh token (long-lived, used\\nto rotate access tokens). Or leave blank to use\\nCOPILOT_ACCESS_TOKEN / ~/.config/copilot/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COPILOT_REFRESH_TOKEN\").\n\t\t\t\tPlaceholder(\"ghu_... or ghr_... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&copilotRefreshToken),\n\t\t).Title(\"2 / 4 · Copilot Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCopilotOAuth) }),\n\n\t\t// Step 2-oauth-iv: Perplexity Pro subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Perplexity Pro Subscription\").\n\t\t\t\tDescription(\"Open perplexity.ai → DevTools → Application →\\nCookies → perplexity.ai → copy the value of\\n`next-auth.session-token`. Or leave blank to use\\nPERPLEXITY_ACCESS_TOKEN / ~/.config/perplexity/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"PERPLEXITY_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste next-auth.session-token (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&perplexitySessionToken),\n\t\t).Title(\"2 / 4 · Perplexity Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodPerplexityOAuth) }),\n\n\t\t// Step 2g: Local Ollama\n\t\t// No API key — the user provides only the endpoint and the model\n\t\t// tag they pulled (``ollama pull qwen3-coder:30b``). The proxy\n\t\t// dynamically registers ``ollama_chat/<model>`` at startup via\n\t\t// litellm_dynamic_config.py — no yaml editing needed. The\n\t\t// ``ollama_chat/`` provider routes to /api/chat which is the\n\t\t// only Ollama endpoint that supports tool/function calling, and\n\t\t// every Decepticon agent uses tools.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Local Ollama\").\n\t\t\t\tDescription(\"Ollama must already be running on your host\\n(`ollama serve`) with the model pulled\\n(`ollama pull <name>`).\\n\\nLeave the URL as-is on macOS / Linux Docker /\\nDocker Desktop on Windows or WSL2 with Ollama\\nrunning on the Windows host.\\n\\nIf Ollama runs INSIDE your WSL distro instead,\\nchange it to http://localhost:11434.\\n\\nFor remote / custom setups use the full URL.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_API_BASE\").\n\t\t\t\tPlaceholder(defaultOllamaAPIBase).\n\t\t\t\tValue(&ollamaAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_MODEL\").\n\t\t\t\tDescription(\"Any tag you pulled. Examples: qwen3-coder:30b,\\nllama3.2, deepseek-r1:14b, mistral-small3.\").\n\t\t\t\tPlaceholder(defaultOllamaModel).\n\t\t\t\tValue(&ollamaModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Local LLM (Ollama)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaLocal) }),\n\n\t\t// Step 3: Model profile\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Model Profile\").\n\t\t\t\tDescription(\"eco per-agent tier (recommended)\\nmax every agent on HIGH (expensive)\\ntest every agent on LOW (development)\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"eco — per-agent tier (recommended)\", \"eco\"),\n\t\t\t\t\thuh.NewOption(\"max — every agent on HIGH (expensive)\", \"max\"),\n\t\t\t\t\thuh.NewOption(\"test — every agent on LOW (development)\", \"test\"),\n\t\t\t\t).\n\t\t\t\tValue(&profile),\n\t\t).Title(\"3 / 4 · Profile\"),\n\n\t\t// Step 4a: LangSmith toggle\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Enable LangSmith?\").\n\t\t\t\tDescription(\"LLM observability and trace collection\").\n\t\t\t\tAffirmative(\"Yes\").\n\t\t\t\tNegative(\"No\").\n\t\t\t\tValue(&useLangSmith),\n\t\t).Title(\"4 / 4 · Observability\"),\n\n\t\t// Step 4b: LangSmith key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LangSmith API Key\").\n\t\t\t\tPlaceholder(\"lsv2_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&langSmithKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"4 / 4 · LangSmith\").\n\t\t\tWithHideFunc(func() bool { return !useLangSmith }),\n\t).WithTheme(huh.ThemeFunc(ui.DecepticonTheme))\n\n\tif err := form.Run(); err != nil {\n\t\treturn fmt.Errorf(\"setup cancelled: %w\", err)\n\t}\n\n\t// huh.MultiSelect returns selected values in option order, not the\n\t// order the user toggled. Re-derive the priority by walking\n\t// methodOrder and keeping only what the user picked.\n\tpriority := make([]string, 0, len(methods))\n\tfor _, m := range methodOrder {\n\t\tif contains(methods, m) {\n\t\t\tpriority = append(priority, m)\n\t\t}\n\t}\n\n\tvalues := map[string]string{\n\t\t\"DECEPTICON_MODEL_PROFILE\": profile,\n\t\t\"DECEPTICON_AUTH_PRIORITY\": strings.Join(priority, \",\"),\n\t\t\"DECEPTICON_AUTH_CLAUDE_CODE\": boolStr(contains(methods, methodAnthropicOAuth)),\n\t\t\"DECEPTICON_AUTH_CHATGPT\": boolStr(contains(methods, methodOpenAIOAuth)),\n\t\t\"DECEPTICON_AUTH_GEMINI\": boolStr(contains(methods, methodGoogleOAuth)),\n\t\t\"DECEPTICON_AUTH_GROK\": boolStr(contains(methods, methodGrokOAuth)),\n\t\t\"DECEPTICON_AUTH_COPILOT\": boolStr(contains(methods, methodCopilotOAuth)),\n\t\t\"DECEPTICON_AUTH_PERPLEXITY\": boolStr(contains(methods, methodPerplexityOAuth)),\n\t}\n\n\tif anthropicKey != \"\" {\n\t\tvalues[\"ANTHROPIC_API_KEY\"] = anthropicKey\n\t}\n\tif openaiKey != \"\" {\n\t\tvalues[\"OPENAI_API_KEY\"] = openaiKey\n\t}\n\tif geminiKey != \"\" {\n\t\tvalues[\"GEMINI_API_KEY\"] = geminiKey\n\t}\n\tif minimaxKey != \"\" {\n\t\tvalues[\"MINIMAX_API_KEY\"] = minimaxKey\n\t}\n\tif deepseekKey != \"\" {\n\t\tvalues[\"DEEPSEEK_API_KEY\"] = deepseekKey\n\t}\n\tif xaiKey != \"\" {\n\t\tvalues[\"XAI_API_KEY\"] = xaiKey\n\t}\n\tif mistralKey != \"\" {\n\t\tvalues[\"MISTRAL_API_KEY\"] = mistralKey\n\t}\n\tif openrouterKey != \"\" {\n\t\tvalues[\"OPENROUTER_API_KEY\"] = openrouterKey\n\t}\n\tif nvidiaKey != \"\" {\n\t\tvalues[\"NVIDIA_API_KEY\"] = nvidiaKey\n\t}\n\tif chatgptSessionToken != \"\" {\n\t\tvalues[\"CHATGPT_SESSION_TOKEN\"] = chatgptSessionToken\n\t}\n\tif geminiSessionCookies != \"\" {\n\t\tvalues[\"GEMINI_SESSION_COOKIES\"] = geminiSessionCookies\n\t}\n\tif grokSessionToken != \"\" {\n\t\tvalues[\"GROK_SESSION_TOKEN\"] = grokSessionToken\n\t}\n\tif copilotRefreshToken != \"\" {\n\t\tvalues[\"COPILOT_REFRESH_TOKEN\"] = copilotRefreshToken\n\t}\n\tif perplexitySessionToken != \"\" {\n\t\tvalues[\"PERPLEXITY_SESSION_TOKEN\"] = perplexitySessionToken\n\t}\n\tif contains(methods, methodOllamaLocal) {\n\t\tvalues[\"OLLAMA_API_BASE\"] = strings.TrimSpace(ollamaAPIBase)\n\t\tvalues[\"OLLAMA_MODEL\"] = strings.TrimSpace(ollamaModel)\n\t}\n\n\tif useLangSmith && langSmithKey != \"\" {\n\t\tvalues[\"LANGSMITH_TRACING\"] = \"true\"\n\t\tvalues[\"LANGSMITH_API_KEY\"] = langSmithKey\n\t\tvalues[\"LANGSMITH_PROJECT\"] = \"decepticon\"\n\t}\n\n\tif err := config.WriteEnvFromEmbed(config.EnvPath(), values); err != nil {\n\t\treturn fmt.Errorf(\"write .env: %w\", err)\n\t}\n\n\t// Summary\n\tfmt.Println()\n\tfmt.Println(ui.Green.Render(\" ✓ Configuration saved\"))\n\tfmt.Println()\n\tfmt.Println(ui.Dim.Render(\" ┌──────────────────────────────────┐\"))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Methods \") + ui.Dim.Render(strings.Join(priority, \", \")))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Profile \") + ui.Dim.Render(profile))\n\tif useLangSmith {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" LangSmith \") + ui.Green.Render(\"enabled\"))\n\t}\n\tfmt.Println(ui.Dim.Render(\" │\"))\n\tfmt.Println(ui.Dim.Render(\" │ \") + ui.Dim.Render(config.EnvPath()))\n\tfmt.Println(ui.Dim.Render(\" └──────────────────────────────────┘\"))\n\tfmt.Println()\n\tui.DimText(\" Run 'decepticon' to start the platform\")\n\treturn nil\n}\n\nfunc contains(haystack []string, needle string) bool {\n\treturn slices.Contains(haystack, needle)\n}\n\nfunc nonEmpty(s string) error {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn fmt.Errorf(\"value is required\")\n\t}\n\treturn nil\n}\n\nfunc boolStr(b bool) string {\n\tif b {\n\t\treturn \"true\"\n\t}\n\treturn \"false\"\n}\n"}, "08e167c36420f3e6c99a535737ff8c45207d3f35": {"t": 1785755411.7843862, "n": 18722, "h": "7f330db69699608d", "c": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"charm.land/huh/v2\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar resetFlag bool\n\nvar onboardCmd = &cobra.Command{\n\tUse: \"onboard\",\n\tShort: \"Configure Decepticon (auth methods, model profile, observability)\",\n\tRunE: runOnboard,\n}\n\nfunc init() {\n\tonboardCmd.Flags().BoolVar(&resetFlag, \"reset\", false, \"Reconfigure even if .env already exists\")\n\trootCmd.AddCommand(onboardCmd)\n}\n\n// AuthMethod identifiers — must match decepticon/llm/models.py::AuthMethod.\nconst (\n\tmethodAnthropicOAuth = \"anthropic_oauth\"\n\tmethodAnthropicAPI = \"anthropic_api\"\n\tmethodOpenAIOAuth = \"openai_oauth\"\n\tmethodOpenAIAPI = \"openai_api\"\n\tmethodGoogleOAuth = \"google_oauth\"\n\tmethodGoogleAPI = \"google_api\"\n\tmethodMiniMaxAPI = \"minimax_api\"\n\tmethodDeepSeekAPI = \"deepseek_api\"\n\tmethodXAIAPI = \"xai_api\"\n\tmethodGrokOAuth = \"grok_oauth\"\n\tmethodMistralAPI = \"mistral_api\"\n\tmethodOpenRouterAPI = \"openrouter_api\"\n\tmethodNvidiaAPI = \"nvidia_api\"\n\tmethodCopilotOAuth = \"copilot_oauth\"\n\tmethodPerplexityOAuth = \"perplexity_oauth\"\n\tmethodOllamaLocal = \"ollama_local\"\n)\n\n// Default Ollama wiring shown to OSS users. host.docker.internal works\n// on Mac/Windows out of the box and on Linux thanks to the\n// ``extra_hosts: [host.docker.internal:host-gateway]`` entry on the\n// litellm service in docker-compose.yml. The default model is the\n// smallest/fastest one most laptops can actually run; users with a GPU\n// will pick something like qwen3-coder:30b.\nconst (\n\tdefaultOllamaAPIBase = \"http://host.docker.internal:11434\"\n\tdefaultOllamaModel = \"llama3.2\"\n)\n\n// methodOrder is the priority order surfaced in the wizard. The\n// resulting DECEPTICON_AUTH_PRIORITY preserves this order, filtered\n// to the methods the user actually selected. OAuth precedes the\n// matching API on purpose: a subscription primary should fall back\n// to the paid API only when the subscription quota is exhausted.\n// Ollama sits last: cloud providers are usually preferred when both\n// are configured, but a user wanting to lead with local-only inference\n// can reorder the priority manually in .env.\nvar methodOrder = []string{\n\tmethodAnthropicOAuth,\n\tmethodAnthropicAPI,\n\tmethodOpenAIOAuth,\n\tmethodOpenAIAPI,\n\tmethodGoogleOAuth,\n\tmethodGoogleAPI,\n\tmethodMiniMaxAPI,\n\tmethodDeepSeekAPI,\n\tmethodXAIAPI,\n\tmethodGrokOAuth,\n\tmethodMistralAPI,\n\tmethodOpenRouterAPI,\n\tmethodNvidiaAPI,\n\tmethodCopilotOAuth,\n\tmethodPerplexityOAuth,\n\tmethodOllamaLocal,\n}\n\nfunc runOnboard(cmd *cobra.Command, args []string) error {\n\tif config.EnvExists() && !resetFlag {\n\t\tui.Info(\".env already configured at \" + config.EnvPath())\n\t\tui.DimText(\"Run 'decepticon onboard --reset' to reconfigure\")\n\t\treturn nil\n\t}\n\n\tvar (\n\t\tmethods []string\n\t\tanthropicKey string\n\t\topenaiKey string\n\t\tgeminiKey string\n\t\tminimaxKey string\n\t\tdeepseekKey string\n\t\txaiKey string\n\t\tmistralKey string\n\t\topenrouterKey string\n\t\tnvidiaKey string\n\t\tchatgptSessionToken string\n\t\tgeminiSessionCookies string\n\t\tcopilotRefreshToken string\n\t\tgrokSessionToken string\n\t\tperplexitySessionToken string\n\t\tollamaAPIBase = defaultOllamaAPIBase\n\t\tollamaModel = defaultOllamaModel\n\t\tprofile string\n\t\tuseLangSmith bool\n\t\tlangSmithKey string\n\t)\n\n\tform := huh.NewForm(\n\t\t// Intro\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Decepticon Setup\").\n\t\t\t\tDescription(\"Configure auth methods, model profile, and\\nobservability.\\n\\nUse ↑↓ to navigate, space to toggle, Enter to confirm.\"),\n\t\t),\n\n\t\t// Step 1: Auth methods (multi-select)\n\t\thuh.NewGroup(\n\t\t\thuh.NewMultiSelect[string]().\n\t\t\t\tTitle(\"Auth Methods\").\n\t\t\t\tDescription(\"Pick every credential you have. Each method is an\\nindependent fallback in priority order shown.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"Claude Code OAuth — Anthropic subscription (auth/*)\", methodAnthropicOAuth),\n\t\t\t\t\thuh.NewOption(\"Anthropic API Key — sk-ant-...\", methodAnthropicAPI),\n\t\t\t\t\thuh.NewOption(\"ChatGPT OAuth — ChatGPT Pro/Plus/Team subscription (auth/gpt-*)\", methodOpenAIOAuth),\n\t\t\t\t\thuh.NewOption(\"OpenAI API Key — sk-...\", methodOpenAIAPI),\n\t\t\t\t\thuh.NewOption(\"Google API Key — AIza... (Gemini)\", methodGoogleAPI),\n\t\t\t\t\thuh.NewOption(\"MiniMax API Key — eyJ...\", methodMiniMaxAPI),\n\t\t\t\t\thuh.NewOption(\"DeepSeek API Key — sk-...\", methodDeepSeekAPI),\n\t\t\t\t\thuh.NewOption(\"xAI API Key — xai-... (Grok)\", methodXAIAPI),\n\t\t\t\t\thuh.NewOption(\"Mistral API Key — (no fixed prefix)\", methodMistralAPI),\n\t\t\t\t\thuh.NewOption(\"OpenRouter API Key — sk-or-...\", methodOpenRouterAPI),\n\t\t\t\t\thuh.NewOption(\"Nvidia NIM API Key — nvapi-...\", methodNvidiaAPI),\n\t\t\t\t\thuh.NewOption(\"Gemini Advanced — Google One AI Premium subscription (gemini-sub/*)\", methodGoogleOAuth),\n\t\t\t\t\thuh.NewOption(\"SuperGrok — X Premium+ Grok subscription (grok-sub/*)\", methodGrokOAuth),\n\t\t\t\t\thuh.NewOption(\"GitHub Copilot Pro — Copilot subscription (copilot/*)\", methodCopilotOAuth),\n\t\t\t\t\thuh.NewOption(\"Perplexity Pro — Perplexity subscription (pplx-sub/*)\", methodPerplexityOAuth),\n\t\t\t\t\thuh.NewOption(\"Local LLM (Ollama) — any pulled model, no API key\", methodOllamaLocal),\n\t\t\t\t).\n\t\t\t\tValue(&methods).\n\t\t\t\tValidate(func(s []string) error {\n\t\t\t\t\tif len(s) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"select at least one credential\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"1 / 4 · Credentials\").\n\t\t\tDescription(\"Select all that apply\"),\n\n\t\t// Step 2a: Anthropic API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Anthropic API Key\").\n\t\t\t\tPlaceholder(\"sk-ant-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&anthropicKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Anthropic API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicAPI) }),\n\n\t\t// Step 2b: ChatGPT subscription session token\n\t\t// ChatGPT OAuth has no equivalent of `claude /login` to provision\n\t\t// tokens automatically, so we ask the user to paste the\n\t\t// `__Secure-next-auth.session-token` cookie from a signed-in\n\t\t// chatgpt.com browser session. Optional — users who set\n\t\t// CHATGPT_ACCESS_TOKEN externally or place ~/.config/chatgpt/tokens.json\n\t\t// can leave this blank and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"ChatGPT Session Token\").\n\t\t\t\tDescription(\"Open chatgpt.com → DevTools → Application →\\nCookies → chatgpt.com → copy the value of\\n`__Secure-next-auth.session-token`. Or leave\\nblank to use CHATGPT_ACCESS_TOKEN / tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CHATGPT_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"eyJhbGciOiJ... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&chatgptSessionToken),\n\t\t).Title(\"2 / 4 · ChatGPT OAuth\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIOAuth) }),\n\n\t\t// Step 2c: OpenAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenAI API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · OpenAI API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIAPI) }),\n\n\t\t// Step 2c: Google API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Google (Gemini) API Key\").\n\t\t\t\tPlaceholder(\"AIza...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Google API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleAPI) }),\n\n\t\t// Step 2d: MiniMax API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MiniMax API Key\").\n\t\t\t\tPlaceholder(\"eyJ...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&minimaxKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · MiniMax API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMiniMaxAPI) }),\n\n\t\t// Step 2d-i: DeepSeek API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"DeepSeek API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&deepseekKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · DeepSeek API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodDeepSeekAPI) }),\n\n\t\t// Step 2d-ii: xAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"xAI API Key\").\n\t\t\t\tPlaceholder(\"xai-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&xaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · xAI API (Grok)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodXAIAPI) }),\n\n\t\t// Step 2d-iii: Mistral API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Mistral API Key\").\n\t\t\t\tPlaceholder(\"paste your Mistral API key\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&mistralKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Mistral API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMistralAPI) }),\n\n\t\t// Step 2e: OpenRouter API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenRouter API Key\").\n\t\t\t\tPlaceholder(\"sk-or-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openrouterKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · OpenRouter API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenRouterAPI) }),\n\n\t\t// Step 2f: Nvidia NIM API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Nvidia NIM API Key\").\n\t\t\t\tPlaceholder(\"nvapi-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&nvidiaKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Nvidia NIM API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodNvidiaAPI) }),\n\n\t\t// Step 2-oauth-i: Gemini Advanced subscription\n\t\t// Multi-cookie value (NID + Secure-1PSID + Secure-1PSIDTS, etc.)\n\t\t// joined with semicolons. Optional — power users can drop a\n\t\t// tokens.json under ~/.config/gemini/ instead and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Gemini Advanced Subscription\").\n\t\t\t\tDescription(\"Open gemini.google.com → DevTools → Application →\\nCookies → gemini.google.com. Copy the values of\\nNID, __Secure-1PSID, __Secure-1PSIDTS as a single\\nsemicolon-joined string. Or leave blank to use\\nGEMINI_ACCESS_TOKEN / ~/.config/gemini/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GEMINI_SESSION_COOKIES\").\n\t\t\t\tPlaceholder(\"NID=...; __Secure-1PSID=... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiSessionCookies),\n\t\t).Title(\"2 / 4 · Gemini Advanced\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleOAuth) }),\n\n\t\t// Step 2-oauth-ii: SuperGrok subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"SuperGrok Subscription\").\n\t\t\t\tDescription(\"Open grok.com → DevTools → Application →\\nCookies → grok.com → copy the value of\\n`auth_token`. Or leave blank to use\\nGROK_ACCESS_TOKEN / ~/.config/grok/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"GROK_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste auth_token cookie value (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&grokSessionToken),\n\t\t).Title(\"2 / 4 · SuperGrok\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGrokOAuth) }),\n\n\t\t// Step 2-oauth-iii: GitHub Copilot Pro subscription\n\t\t// Refresh token rotation, not a single session cookie. Devs can\n\t\t// extract one from VSCode's Copilot extension config or follow\n\t\t// gh-copilot-cli onboarding. Optional via tokens.json fallback.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"GitHub Copilot Pro Subscription\").\n\t\t\t\tDescription(\"Provide a Copilot refresh token (long-lived, used\\nto rotate access tokens). Or leave blank to use\\nCOPILOT_ACCESS_TOKEN / ~/.config/copilot/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"COPILOT_REFRESH_TOKEN\").\n\t\t\t\tPlaceholder(\"ghu_... or ghr_... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&copilotRefreshToken),\n\t\t).Title(\"2 / 4 · Copilot Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodCopilotOAuth) }),\n\n\t\t// Step 2-oauth-iv: Perplexity Pro subscription\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Perplexity Pro Subscription\").\n\t\t\t\tDescription(\"Open perplexity.ai → DevTools → Application →\\nCookies → perplexity.ai → copy the value of\\n`next-auth.session-token`. Or leave blank to use\\nPERPLEXITY_ACCESS_TOKEN / ~/.config/perplexity/tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"PERPLEXITY_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"paste next-auth.session-token (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&perplexitySessionToken),\n\t\t).Title(\"2 / 4 · Perplexity Pro\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodPerplexityOAuth) }),\n\n\t\t// Step 2g: Local Ollama\n\t\t// No API key — the user provides only the endpoint and the model\n\t\t// tag they pulled (``ollama pull qwen3-coder:30b``). The proxy\n\t\t// dynamically registers ``ollama_chat/<model>`` at startup via\n\t\t// litellm_dynamic_config.py — no yaml editing needed. The\n\t\t// ``ollama_chat/`` provider routes to /api/chat which is the\n\t\t// only Ollama endpoint that supports tool/function calling, and\n\t\t// every Decepticon agent uses tools.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Local Ollama\").\n\t\t\t\tDescription(\"Ollama must already be running on your host\\n(`ollama serve`) with the model pulled\\n(`ollama pull <name>`).\\n\\nLeave the URL as-is on Mac/Windows/Linux Docker —\\n`host.docker.internal:host-gateway` is wired up.\\nIf you run Ollama in a custom location, override\\nthe URL below.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_API_BASE\").\n\t\t\t\tPlaceholder(defaultOllamaAPIBase).\n\t\t\t\tValue(&ollamaAPIBase).\n\t\t\t\tValidate(nonEmpty),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OLLAMA_MODEL\").\n\t\t\t\tDescription(\"Any tag you pulled. Examples: qwen3-coder:30b,\\nllama3.2, deepseek-r1:14b, mistral-small3.\").\n\t\t\t\tPlaceholder(defaultOllamaModel).\n\t\t\t\tValue(&ollamaModel).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Local LLM (Ollama)\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOllamaLocal) }),\n\n\t\t// Step 3: Model profile\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Model Profile\").\n\t\t\t\tDescription(\"eco per-agent tier (recommended)\\nmax every agent on HIGH (expensive)\\ntest every agent on LOW (development)\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"eco — per-agent tier (recommended)\", \"eco\"),\n\t\t\t\t\thuh.NewOption(\"max — every agent on HIGH (expensive)\", \"max\"),\n\t\t\t\t\thuh.NewOption(\"test — every agent on LOW (development)\", \"test\"),\n\t\t\t\t).\n\t\t\t\tValue(&profile),\n\t\t).Title(\"3 / 4 · Profile\"),\n\n\t\t// Step 4a: LangSmith toggle\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Enable LangSmith?\").\n\t\t\t\tDescription(\"LLM observability and trace collection\").\n\t\t\t\tAffirmative(\"Yes\").\n\t\t\t\tNegative(\"No\").\n\t\t\t\tValue(&useLangSmith),\n\t\t).Title(\"4 / 4 · Observability\"),\n\n\t\t// Step 4b: LangSmith key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LangSmith API Key\").\n\t\t\t\tPlaceholder(\"lsv2_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&langSmithKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"4 / 4 · LangSmith\").\n\t\t\tWithHideFunc(func() bool { return !useLangSmith }),\n\t).WithTheme(huh.ThemeFunc(ui.DecepticonTheme))\n\n\tif err := form.Run(); err != nil {\n\t\treturn fmt.Errorf(\"setup cancelled: %w\", err)\n\t}\n\n\t// huh.MultiSelect returns selected values in option order, not the\n\t// order the user toggled. Re-derive the priority by walking\n\t// methodOrder and keeping only what the user picked.\n\tpriority := make([]string, 0, len(methods))\n\tfor _, m := range methodOrder {\n\t\tif contains(methods, m) {\n\t\t\tpriority = append(priority, m)\n\t\t}\n\t}\n\n\tvalues := map[string]string{\n\t\t\"DECEPTICON_MODEL_PROFILE\": profile,\n\t\t\"DECEPTICON_AUTH_PRIORITY\": strings.Join(priority, \",\"),\n\t\t\"DECEPTICON_AUTH_CLAUDE_CODE\": boolStr(contains(methods, methodAnthropicOAuth)),\n\t\t\"DECEPTICON_AUTH_CHATGPT\": boolStr(contains(methods, methodOpenAIOAuth)),\n\t\t\"DECEPTICON_AUTH_GEMINI\": boolStr(contains(methods, methodGoogleOAuth)),\n\t\t\"DECEPTICON_AUTH_GROK\": boolStr(contains(methods, methodGrokOAuth)),\n\t\t\"DECEPTICON_AUTH_COPILOT\": boolStr(contains(methods, methodCopilotOAuth)),\n\t\t\"DECEPTICON_AUTH_PERPLEXITY\": boolStr(contains(methods, methodPerplexityOAuth)),\n\t}\n\n\tif anthropicKey != \"\" {\n\t\tvalues[\"ANTHROPIC_API_KEY\"] = anthropicKey\n\t}\n\tif openaiKey != \"\" {\n\t\tvalues[\"OPENAI_API_KEY\"] = openaiKey\n\t}\n\tif geminiKey != \"\" {\n\t\tvalues[\"GEMINI_API_KEY\"] = geminiKey\n\t}\n\tif minimaxKey != \"\" {\n\t\tvalues[\"MINIMAX_API_KEY\"] = minimaxKey\n\t}\n\tif deepseekKey != \"\" {\n\t\tvalues[\"DEEPSEEK_API_KEY\"] = deepseekKey\n\t}\n\tif xaiKey != \"\" {\n\t\tvalues[\"XAI_API_KEY\"] = xaiKey\n\t}\n\tif mistralKey != \"\" {\n\t\tvalues[\"MISTRAL_API_KEY\"] = mistralKey\n\t}\n\tif openrouterKey != \"\" {\n\t\tvalues[\"OPENROUTER_API_KEY\"] = openrouterKey\n\t}\n\tif nvidiaKey != \"\" {\n\t\tvalues[\"NVIDIA_API_KEY\"] = nvidiaKey\n\t}\n\tif chatgptSessionToken != \"\" {\n\t\tvalues[\"CHATGPT_SESSION_TOKEN\"] = chatgptSessionToken\n\t}\n\tif geminiSessionCookies != \"\" {\n\t\tvalues[\"GEMINI_SESSION_COOKIES\"] = geminiSessionCookies\n\t}\n\tif grokSessionToken != \"\" {\n\t\tvalues[\"GROK_SESSION_TOKEN\"] = grokSessionToken\n\t}\n\tif copilotRefreshToken != \"\" {\n\t\tvalues[\"COPILOT_REFRESH_TOKEN\"] = copilotRefreshToken\n\t}\n\tif perplexitySessionToken != \"\" {\n\t\tvalues[\"PERPLEXITY_SESSION_TOKEN\"] = perplexitySessionToken\n\t}\n\tif contains(methods, methodOllamaLocal) {\n\t\tvalues[\"OLLAMA_API_BASE\"] = strings.TrimSpace(ollamaAPIBase)\n\t\tvalues[\"OLLAMA_MODEL\"] = strings.TrimSpace(ollamaModel)\n\t}\n\n\tif useLangSmith && langSmithKey != \"\" {\n\t\tvalues[\"LANGSMITH_TRACING\"] = \"true\"\n\t\tvalues[\"LANGSMITH_API_KEY\"] = langSmithKey\n\t\tvalues[\"LANGSMITH_PROJECT\"] = \"decepticon\"\n\t}\n\n\tif err := config.WriteEnvFromEmbed(config.EnvPath(), values); err != nil {\n\t\treturn fmt.Errorf(\"write .env: %w\", err)\n\t}\n\n\t// Summary\n\tfmt.Println()\n\tfmt.Println(ui.Green.Render(\" ✓ Configuration saved\"))\n\tfmt.Println()\n\tfmt.Println(ui.Dim.Render(\" ┌──────────────────────────────────┐\"))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Methods \") + ui.Dim.Render(strings.Join(priority, \", \")))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Profile \") + ui.Dim.Render(profile))\n\tif useLangSmith {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" LangSmith \") + ui.Green.Render(\"enabled\"))\n\t}\n\tfmt.Println(ui.Dim.Render(\" │\"))\n\tfmt.Println(ui.Dim.Render(\" │ \") + ui.Dim.Render(config.EnvPath()))\n\tfmt.Println(ui.Dim.Render(\" └──────────────────────────────────┘\"))\n\tfmt.Println()\n\tui.DimText(\" Run 'decepticon' to start the platform\")\n\treturn nil\n}\n\nfunc contains(haystack []string, needle string) bool {\n\treturn slices.Contains(haystack, needle)\n}\n\nfunc nonEmpty(s string) error {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn fmt.Errorf(\"value is required\")\n\t}\n\treturn nil\n}\n\nfunc boolStr(b bool) string {\n\tif b {\n\t\treturn \"true\"\n\t}\n\treturn \"false\"\n}\n"}, "59b32a4d9f3fcb96f02acb56b510842d9d4c74e6": {"t": 1785755412.974693, "n": 10098, "h": "9d91d211dd678a05", "c": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"charm.land/huh/v2\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar resetFlag bool\n\nvar onboardCmd = &cobra.Command{\n\tUse: \"onboard\",\n\tShort: \"Configure Decepticon (auth methods, model profile, observability)\",\n\tRunE: runOnboard,\n}\n\nfunc init() {\n\tonboardCmd.Flags().BoolVar(&resetFlag, \"reset\", false, \"Reconfigure even if .env already exists\")\n\trootCmd.AddCommand(onboardCmd)\n}\n\n// AuthMethod identifiers — must match decepticon/llm/models.py::AuthMethod.\nconst (\n\tmethodAnthropicOAuth = \"anthropic_oauth\"\n\tmethodAnthropicAPI = \"anthropic_api\"\n\tmethodOpenAIOAuth = \"openai_oauth\"\n\tmethodOpenAIAPI = \"openai_api\"\n\tmethodGoogleAPI = \"google_api\"\n\tmethodMiniMaxAPI = \"minimax_api\"\n\tmethodOpenRouterAPI = \"openrouter_api\"\n\tmethodNvidiaAPI = \"nvidia_api\"\n)\n\n// methodOrder is the priority order surfaced in the wizard. The\n// resulting DECEPTICON_AUTH_PRIORITY preserves this order, filtered\n// to the methods the user actually selected. OAuth precedes the\n// matching API on purpose: a subscription primary should fall back\n// to the paid API only when the subscription quota is exhausted.\nvar methodOrder = []string{\n\tmethodAnthropicOAuth,\n\tmethodAnthropicAPI,\n\tmethodOpenAIOAuth,\n\tmethodOpenAIAPI,\n\tmethodGoogleAPI,\n\tmethodMiniMaxAPI,\n\tmethodOpenRouterAPI,\n\tmethodNvidiaAPI,\n}\n\nfunc runOnboard(cmd *cobra.Command, args []string) error {\n\tif config.EnvExists() && !resetFlag {\n\t\tui.Info(\".env already configured at \" + config.EnvPath())\n\t\tui.DimText(\"Run 'decepticon onboard --reset' to reconfigure\")\n\t\treturn nil\n\t}\n\n\tvar (\n\t\tmethods []string\n\t\tanthropicKey string\n\t\topenaiKey string\n\t\tgeminiKey string\n\t\tminimaxKey string\n\t\topenrouterKey string\n\t\tnvidiaKey string\n\t\tchatgptSessionToken string\n\t\tprofile string\n\t\tuseLangSmith bool\n\t\tlangSmithKey string\n\t)\n\n\tform := huh.NewForm(\n\t\t// Intro\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Decepticon Setup\").\n\t\t\t\tDescription(\"Configure auth methods, model profile, and\\nobservability.\\n\\nUse ↑↓ to navigate, space to toggle, Enter to confirm.\"),\n\t\t),\n\n\t\t// Step 1: Auth methods (multi-select)\n\t\thuh.NewGroup(\n\t\t\thuh.NewMultiSelect[string]().\n\t\t\t\tTitle(\"Auth Methods\").\n\t\t\t\tDescription(\"Pick every credential you have. Each method is an\\nindependent fallback in priority order shown.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"Claude Code OAuth — Anthropic subscription (auth/*)\", methodAnthropicOAuth),\n\t\t\t\t\thuh.NewOption(\"Anthropic API Key — sk-ant-...\", methodAnthropicAPI),\n\t\t\t\t\thuh.NewOption(\"ChatGPT OAuth — ChatGPT Pro/Plus/Team subscription (auth/gpt-*)\", methodOpenAIOAuth),\n\t\t\t\t\thuh.NewOption(\"OpenAI API Key — sk-...\", methodOpenAIAPI),\n\t\t\t\t\thuh.NewOption(\"Google API Key — AIza... (Gemini)\", methodGoogleAPI),\n\t\t\t\t\thuh.NewOption(\"MiniMax API Key — eyJ...\", methodMiniMaxAPI),\n\t\t\t\t\thuh.NewOption(\"OpenRouter API Key — sk-or-...\", methodOpenRouterAPI),\n\t\t\t\t\thuh.NewOption(\"Nvidia NIM API Key — nvapi-...\", methodNvidiaAPI),\n\t\t\t\t).\n\t\t\t\tValue(&methods).\n\t\t\t\tValidate(func(s []string) error {\n\t\t\t\t\tif len(s) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"select at least one credential\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"1 / 4 · Credentials\").\n\t\t\tDescription(\"Select all that apply\"),\n\n\t\t// Step 2a: Anthropic API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Anthropic API Key\").\n\t\t\t\tPlaceholder(\"sk-ant-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&anthropicKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Anthropic API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicAPI) }),\n\n\t\t// Step 2b: ChatGPT subscription session token\n\t\t// ChatGPT OAuth has no equivalent of `claude /login` to provision\n\t\t// tokens automatically, so we ask the user to paste the\n\t\t// `__Secure-next-auth.session-token` cookie from a signed-in\n\t\t// chatgpt.com browser session. Optional — users who set\n\t\t// CHATGPT_ACCESS_TOKEN externally or place ~/.config/chatgpt/tokens.json\n\t\t// can leave this blank and skip with Enter.\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"ChatGPT Session Token\").\n\t\t\t\tDescription(\"Open chatgpt.com → DevTools → Application →\\nCookies → chatgpt.com → copy the value of\\n`__Secure-next-auth.session-token`. Or leave\\nblank to use CHATGPT_ACCESS_TOKEN / tokens.json.\"),\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"CHATGPT_SESSION_TOKEN\").\n\t\t\t\tPlaceholder(\"eyJhbGciOiJ... (leave blank to skip)\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&chatgptSessionToken),\n\t\t).Title(\"2 / 4 · ChatGPT OAuth\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIOAuth) }),\n\n\t\t// Step 2c: OpenAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenAI API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · OpenAI API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIAPI) }),\n\n\t\t// Step 2c: Google API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Google (Gemini) API Key\").\n\t\t\t\tPlaceholder(\"AIza...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Google API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleAPI) }),\n\n\t\t// Step 2d: MiniMax API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MiniMax API Key\").\n\t\t\t\tPlaceholder(\"eyJ...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&minimaxKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · MiniMax API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMiniMaxAPI) }),\n\n\t\t// Step 2e: OpenRouter API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenRouter API Key\").\n\t\t\t\tPlaceholder(\"sk-or-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openrouterKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · OpenRouter API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenRouterAPI) }),\n\n\t\t// Step 2f: Nvidia NIM API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Nvidia NIM API Key\").\n\t\t\t\tPlaceholder(\"nvapi-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&nvidiaKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Nvidia NIM API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodNvidiaAPI) }),\n\n\t\t// Step 3: Model profile\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Model Profile\").\n\t\t\t\tDescription(\"eco per-agent tier (recommended)\\nmax every agent on HIGH (expensive)\\ntest every agent on LOW (development)\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"eco — per-agent tier (recommended)\", \"eco\"),\n\t\t\t\t\thuh.NewOption(\"max — every agent on HIGH (expensive)\", \"max\"),\n\t\t\t\t\thuh.NewOption(\"test — every agent on LOW (development)\", \"test\"),\n\t\t\t\t).\n\t\t\t\tValue(&profile),\n\t\t).Title(\"3 / 4 · Profile\"),\n\n\t\t// Step 4a: LangSmith toggle\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Enable LangSmith?\").\n\t\t\t\tDescription(\"LLM observability and trace collection\").\n\t\t\t\tAffirmative(\"Yes\").\n\t\t\t\tNegative(\"No\").\n\t\t\t\tValue(&useLangSmith),\n\t\t).Title(\"4 / 4 · Observability\"),\n\n\t\t// Step 4b: LangSmith key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LangSmith API Key\").\n\t\t\t\tPlaceholder(\"lsv2_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&langSmithKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"4 / 4 · LangSmith\").\n\t\t\tWithHideFunc(func() bool { return !useLangSmith }),\n\t).WithTheme(huh.ThemeFunc(ui.DecepticonTheme))\n\n\tif err := form.Run(); err != nil {\n\t\treturn fmt.Errorf(\"setup cancelled: %w\", err)\n\t}\n\n\t// huh.MultiSelect returns selected values in option order, not the\n\t// order the user toggled. Re-derive the priority by walking\n\t// methodOrder and keeping only what the user picked.\n\tpriority := make([]string, 0, len(methods))\n\tfor _, m := range methodOrder {\n\t\tif contains(methods, m) {\n\t\t\tpriority = append(priority, m)\n\t\t}\n\t}\n\n\tvalues := map[string]string{\n\t\t\"DECEPTICON_MODEL_PROFILE\": profile,\n\t\t\"DECEPTICON_AUTH_PRIORITY\": strings.Join(priority, \",\"),\n\t\t\"DECEPTICON_AUTH_CLAUDE_CODE\": boolStr(contains(methods, methodAnthropicOAuth)),\n\t\t\"DECEPTICON_AUTH_CHATGPT\": boolStr(contains(methods, methodOpenAIOAuth)),\n\t}\n\n\tif anthropicKey != \"\" {\n\t\tvalues[\"ANTHROPIC_API_KEY\"] = anthropicKey\n\t}\n\tif openaiKey != \"\" {\n\t\tvalues[\"OPENAI_API_KEY\"] = openaiKey\n\t}\n\tif geminiKey != \"\" {\n\t\tvalues[\"GEMINI_API_KEY\"] = geminiKey\n\t}\n\tif minimaxKey != \"\" {\n\t\tvalues[\"MINIMAX_API_KEY\"] = minimaxKey\n\t}\n\tif openrouterKey != \"\" {\n\t\tvalues[\"OPENROUTER_API_KEY\"] = openrouterKey\n\t}\n\tif nvidiaKey != \"\" {\n\t\tvalues[\"NVIDIA_API_KEY\"] = nvidiaKey\n\t}\n\tif chatgptSessionToken != \"\" {\n\t\tvalues[\"CHATGPT_SESSION_TOKEN\"] = chatgptSessionToken\n\t}\n\n\tif useLangSmith && langSmithKey != \"\" {\n\t\tvalues[\"LANGSMITH_TRACING\"] = \"true\"\n\t\tvalues[\"LANGSMITH_API_KEY\"] = langSmithKey\n\t\tvalues[\"LANGSMITH_PROJECT\"] = \"decepticon\"\n\t}\n\n\tif err := config.WriteEnvFromEmbed(config.EnvPath(), values); err != nil {\n\t\treturn fmt.Errorf(\"write .env: %w\", err)\n\t}\n\n\t// Summary\n\tfmt.Println()\n\tfmt.Println(ui.Green.Render(\" ✓ Configuration saved\"))\n\tfmt.Println()\n\tfmt.Println(ui.Dim.Render(\" ┌──────────────────────────────────┐\"))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Methods \") + ui.Dim.Render(strings.Join(priority, \", \")))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Profile \") + ui.Dim.Render(profile))\n\tif useLangSmith {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" LangSmith \") + ui.Green.Render(\"enabled\"))\n\t}\n\tfmt.Println(ui.Dim.Render(\" │\"))\n\tfmt.Println(ui.Dim.Render(\" │ \") + ui.Dim.Render(config.EnvPath()))\n\tfmt.Println(ui.Dim.Render(\" └──────────────────────────────────┘\"))\n\tfmt.Println()\n\tui.DimText(\" Run 'decepticon' to start the platform\")\n\treturn nil\n}\n\nfunc contains(haystack []string, needle string) bool {\n\treturn slices.Contains(haystack, needle)\n}\n\nfunc nonEmpty(s string) error {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn fmt.Errorf(\"value is required\")\n\t}\n\treturn nil\n}\n\nfunc boolStr(b bool) string {\n\tif b {\n\t\treturn \"true\"\n\t}\n\treturn \"false\"\n}\n"}, "fe2fed1ac7ab9c30910d8eb1342b1dcdf3b39c84": {"t": 1785755416.5190172, "n": 8672, "h": "97176255ca740be0", "c": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"charm.land/huh/v2\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar resetFlag bool\n\nvar onboardCmd = &cobra.Command{\n\tUse: \"onboard\",\n\tShort: \"Configure Decepticon (auth methods, model profile, observability)\",\n\tRunE: runOnboard,\n}\n\nfunc init() {\n\tonboardCmd.Flags().BoolVar(&resetFlag, \"reset\", false, \"Reconfigure even if .env already exists\")\n\trootCmd.AddCommand(onboardCmd)\n}\n\n// AuthMethod identifiers — must match decepticon/llm/models.py::AuthMethod.\nconst (\n\tmethodAnthropicOAuth = \"anthropic_oauth\"\n\tmethodAnthropicAPI = \"anthropic_api\"\n\tmethodOpenAIAPI = \"openai_api\"\n\tmethodGoogleAPI = \"google_api\"\n\tmethodMiniMaxAPI = \"minimax_api\"\n\tmethodOpenRouterAPI = \"openrouter_api\"\n\tmethodNvidiaAPI = \"nvidia_api\"\n)\n\n// methodOrder is the priority order surfaced in the wizard. The\n// resulting DECEPTICON_AUTH_PRIORITY preserves this order, filtered\n// to the methods the user actually selected. OAuth precedes the\n// matching API on purpose: a subscription primary should fall back\n// to the paid API only when the subscription quota is exhausted.\nvar methodOrder = []string{\n\tmethodAnthropicOAuth,\n\tmethodAnthropicAPI,\n\tmethodOpenAIAPI,\n\tmethodGoogleAPI,\n\tmethodMiniMaxAPI,\n\tmethodOpenRouterAPI,\n\tmethodNvidiaAPI,\n}\n\nfunc runOnboard(cmd *cobra.Command, args []string) error {\n\tif config.EnvExists() && !resetFlag {\n\t\tui.Info(\".env already configured at \" + config.EnvPath())\n\t\tui.DimText(\"Run 'decepticon onboard --reset' to reconfigure\")\n\t\treturn nil\n\t}\n\n\tvar (\n\t\tmethods []string\n\t\tanthropicKey string\n\t\topenaiKey string\n\t\tgeminiKey string\n\t\tminimaxKey string\n\t\topenrouterKey string\n\t\tnvidiaKey string\n\t\tprofile string\n\t\tuseLangSmith bool\n\t\tlangSmithKey string\n\t)\n\n\tform := huh.NewForm(\n\t\t// Intro\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Decepticon Setup\").\n\t\t\t\tDescription(\"Configure auth methods, model profile, and\\nobservability.\\n\\nUse ↑↓ to navigate, space to toggle, Enter to confirm.\"),\n\t\t),\n\n\t\t// Step 1: Auth methods (multi-select)\n\t\thuh.NewGroup(\n\t\t\thuh.NewMultiSelect[string]().\n\t\t\t\tTitle(\"Auth Methods\").\n\t\t\t\tDescription(\"Pick every credential you have. Each method is an\\nindependent fallback in priority order shown.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"Claude Code OAuth — Anthropic subscription (auth/*)\", methodAnthropicOAuth),\n\t\t\t\t\thuh.NewOption(\"Anthropic API Key — sk-ant-...\", methodAnthropicAPI),\n\t\t\t\t\thuh.NewOption(\"OpenAI API Key — sk-...\", methodOpenAIAPI),\n\t\t\t\t\thuh.NewOption(\"Google API Key — AIza... (Gemini)\", methodGoogleAPI),\n\t\t\t\t\thuh.NewOption(\"MiniMax API Key — eyJ...\", methodMiniMaxAPI),\n\t\t\t\t\thuh.NewOption(\"OpenRouter API Key — sk-or-...\", methodOpenRouterAPI),\n\t\t\t\t\thuh.NewOption(\"Nvidia NIM API Key — nvapi-...\", methodNvidiaAPI),\n\t\t\t\t).\n\t\t\t\tValue(&methods).\n\t\t\t\tValidate(func(s []string) error {\n\t\t\t\t\tif len(s) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"select at least one credential\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"1 / 4 · Credentials\").\n\t\t\tDescription(\"Select all that apply\"),\n\n\t\t// Step 2a: Anthropic API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Anthropic API Key\").\n\t\t\t\tPlaceholder(\"sk-ant-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&anthropicKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Anthropic API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicAPI) }),\n\n\t\t// Step 2b: OpenAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenAI API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · OpenAI API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIAPI) }),\n\n\t\t// Step 2c: Google API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Google (Gemini) API Key\").\n\t\t\t\tPlaceholder(\"AIza...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Google API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleAPI) }),\n\n\t\t// Step 2d: MiniMax API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MiniMax API Key\").\n\t\t\t\tPlaceholder(\"eyJ...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&minimaxKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · MiniMax API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMiniMaxAPI) }),\n\n\t\t// Step 2e: OpenRouter API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenRouter API Key\").\n\t\t\t\tPlaceholder(\"sk-or-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openrouterKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · OpenRouter API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenRouterAPI) }),\n\n\t\t// Step 2f: Nvidia NIM API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Nvidia NIM API Key\").\n\t\t\t\tPlaceholder(\"nvapi-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&nvidiaKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Nvidia NIM API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodNvidiaAPI) }),\n\n\t\t// Step 3: Model profile\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Model Profile\").\n\t\t\t\tDescription(\"eco per-agent tier (recommended)\\nmax every agent on HIGH (expensive)\\ntest every agent on LOW (development)\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"eco — per-agent tier (recommended)\", \"eco\"),\n\t\t\t\t\thuh.NewOption(\"max — every agent on HIGH (expensive)\", \"max\"),\n\t\t\t\t\thuh.NewOption(\"test — every agent on LOW (development)\", \"test\"),\n\t\t\t\t).\n\t\t\t\tValue(&profile),\n\t\t).Title(\"3 / 4 · Profile\"),\n\n\t\t// Step 4a: LangSmith toggle\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Enable LangSmith?\").\n\t\t\t\tDescription(\"LLM observability and trace collection\").\n\t\t\t\tAffirmative(\"Yes\").\n\t\t\t\tNegative(\"No\").\n\t\t\t\tValue(&useLangSmith),\n\t\t).Title(\"4 / 4 · Observability\"),\n\n\t\t// Step 4b: LangSmith key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LangSmith API Key\").\n\t\t\t\tPlaceholder(\"lsv2_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&langSmithKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"4 / 4 · LangSmith\").\n\t\t\tWithHideFunc(func() bool { return !useLangSmith }),\n\t).WithTheme(huh.ThemeFunc(ui.DecepticonTheme))\n\n\tif err := form.Run(); err != nil {\n\t\treturn fmt.Errorf(\"setup cancelled: %w\", err)\n\t}\n\n\t// huh.MultiSelect returns selected values in option order, not the\n\t// order the user toggled. Re-derive the priority by walking\n\t// methodOrder and keeping only what the user picked.\n\tpriority := make([]string, 0, len(methods))\n\tfor _, m := range methodOrder {\n\t\tif contains(methods, m) {\n\t\t\tpriority = append(priority, m)\n\t\t}\n\t}\n\n\tvalues := map[string]string{\n\t\t\"DECEPTICON_MODEL_PROFILE\": profile,\n\t\t\"DECEPTICON_AUTH_PRIORITY\": strings.Join(priority, \",\"),\n\t\t\"DECEPTICON_AUTH_CLAUDE_CODE\": boolStr(contains(methods, methodAnthropicOAuth)),\n\t}\n\n\tif anthropicKey != \"\" {\n\t\tvalues[\"ANTHROPIC_API_KEY\"] = anthropicKey\n\t}\n\tif openaiKey != \"\" {\n\t\tvalues[\"OPENAI_API_KEY\"] = openaiKey\n\t}\n\tif geminiKey != \"\" {\n\t\tvalues[\"GEMINI_API_KEY\"] = geminiKey\n\t}\n\tif minimaxKey != \"\" {\n\t\tvalues[\"MINIMAX_API_KEY\"] = minimaxKey\n\t}\n\tif openrouterKey != \"\" {\n\t\tvalues[\"OPENROUTER_API_KEY\"] = openrouterKey\n\t}\n\tif nvidiaKey != \"\" {\n\t\tvalues[\"NVIDIA_API_KEY\"] = nvidiaKey\n\t}\n\n\tif useLangSmith && langSmithKey != \"\" {\n\t\tvalues[\"LANGSMITH_TRACING\"] = \"true\"\n\t\tvalues[\"LANGSMITH_API_KEY\"] = langSmithKey\n\t\tvalues[\"LANGSMITH_PROJECT\"] = \"decepticon\"\n\t}\n\n\tif err := config.WriteEnvFromEmbed(config.EnvPath(), values); err != nil {\n\t\treturn fmt.Errorf(\"write .env: %w\", err)\n\t}\n\n\t// Summary\n\tfmt.Println()\n\tfmt.Println(ui.Green.Render(\" ✓ Configuration saved\"))\n\tfmt.Println()\n\tfmt.Println(ui.Dim.Render(\" ┌──────────────────────────────────┐\"))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Methods \") + ui.Dim.Render(strings.Join(priority, \", \")))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Profile \") + ui.Dim.Render(profile))\n\tif useLangSmith {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" LangSmith \") + ui.Green.Render(\"enabled\"))\n\t}\n\tfmt.Println(ui.Dim.Render(\" │\"))\n\tfmt.Println(ui.Dim.Render(\" │ \") + ui.Dim.Render(config.EnvPath()))\n\tfmt.Println(ui.Dim.Render(\" └──────────────────────────────────┘\"))\n\tfmt.Println()\n\tui.DimText(\" Run 'decepticon' to start the platform\")\n\treturn nil\n}\n\nfunc contains(haystack []string, needle string) bool {\n\treturn slices.Contains(haystack, needle)\n}\n\nfunc nonEmpty(s string) error {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn fmt.Errorf(\"value is required\")\n\t}\n\treturn nil\n}\n\nfunc boolStr(b bool) string {\n\tif b {\n\t\treturn \"true\"\n\t}\n\treturn \"false\"\n}\n"}, "7f4a1df8e7c4a114f06145b0d31cc04da3f7f09b": {"t": 1785755418.0680606, "n": 7585, "h": "8d5419ac0c3d9d5f", "c": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"charm.land/huh/v2\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar resetFlag bool\n\nvar onboardCmd = &cobra.Command{\n\tUse: \"onboard\",\n\tShort: \"Configure Decepticon (auth methods, model profile, observability)\",\n\tRunE: runOnboard,\n}\n\nfunc init() {\n\tonboardCmd.Flags().BoolVar(&resetFlag, \"reset\", false, \"Reconfigure even if .env already exists\")\n\trootCmd.AddCommand(onboardCmd)\n}\n\n// AuthMethod identifiers — must match decepticon/llm/models.py::AuthMethod.\nconst (\n\tmethodAnthropicOAuth = \"anthropic_oauth\"\n\tmethodAnthropicAPI = \"anthropic_api\"\n\tmethodOpenAIAPI = \"openai_api\"\n\tmethodGoogleAPI = \"google_api\"\n\tmethodMiniMaxAPI = \"minimax_api\"\n)\n\n// methodOrder is the priority order surfaced in the wizard. The\n// resulting DECEPTICON_AUTH_PRIORITY preserves this order, filtered\n// to the methods the user actually selected. OAuth precedes the\n// matching API on purpose: a subscription primary should fall back\n// to the paid API only when the subscription quota is exhausted.\nvar methodOrder = []string{\n\tmethodAnthropicOAuth,\n\tmethodAnthropicAPI,\n\tmethodOpenAIAPI,\n\tmethodGoogleAPI,\n\tmethodMiniMaxAPI,\n}\n\nfunc runOnboard(cmd *cobra.Command, args []string) error {\n\tif config.EnvExists() && !resetFlag {\n\t\tui.Info(\".env already configured at \" + config.EnvPath())\n\t\tui.DimText(\"Run 'decepticon onboard --reset' to reconfigure\")\n\t\treturn nil\n\t}\n\n\tvar (\n\t\tmethods []string\n\t\tanthropicKey string\n\t\topenaiKey string\n\t\tgeminiKey string\n\t\tminimaxKey string\n\t\tprofile string\n\t\tuseLangSmith bool\n\t\tlangSmithKey string\n\t)\n\n\tform := huh.NewForm(\n\t\t// Intro\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Decepticon Setup\").\n\t\t\t\tDescription(\"Configure auth methods, model profile, and\\nobservability.\\n\\nUse ↑↓ to navigate, space to toggle, Enter to confirm.\"),\n\t\t),\n\n\t\t// Step 1: Auth methods (multi-select)\n\t\thuh.NewGroup(\n\t\t\thuh.NewMultiSelect[string]().\n\t\t\t\tTitle(\"Auth Methods\").\n\t\t\t\tDescription(\"Pick every credential you have. Each method is an\\nindependent fallback in priority order shown.\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"Claude Code OAuth — Anthropic subscription (auth/*)\", methodAnthropicOAuth),\n\t\t\t\t\thuh.NewOption(\"Anthropic API Key — sk-ant-...\", methodAnthropicAPI),\n\t\t\t\t\thuh.NewOption(\"OpenAI API Key — sk-...\", methodOpenAIAPI),\n\t\t\t\t\thuh.NewOption(\"Google API Key — AIza... (Gemini)\", methodGoogleAPI),\n\t\t\t\t\thuh.NewOption(\"MiniMax API Key — eyJ...\", methodMiniMaxAPI),\n\t\t\t\t).\n\t\t\t\tValue(&methods).\n\t\t\t\tValidate(func(s []string) error {\n\t\t\t\t\tif len(s) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"select at least one credential\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"1 / 4 · Credentials\").\n\t\t\tDescription(\"Select all that apply\"),\n\n\t\t// Step 2a: Anthropic API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Anthropic API Key\").\n\t\t\t\tPlaceholder(\"sk-ant-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&anthropicKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Anthropic API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodAnthropicAPI) }),\n\n\t\t// Step 2b: OpenAI API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"OpenAI API Key\").\n\t\t\t\tPlaceholder(\"sk-...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&openaiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · OpenAI API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodOpenAIAPI) }),\n\n\t\t// Step 2c: Google API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"Google (Gemini) API Key\").\n\t\t\t\tPlaceholder(\"AIza...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&geminiKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · Google API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodGoogleAPI) }),\n\n\t\t// Step 2d: MiniMax API key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"MiniMax API Key\").\n\t\t\t\tPlaceholder(\"eyJ...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&minimaxKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"2 / 4 · MiniMax API\").\n\t\t\tWithHideFunc(func() bool { return !contains(methods, methodMiniMaxAPI) }),\n\n\t\t// Step 3: Model profile\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Model Profile\").\n\t\t\t\tDescription(\"eco per-agent tier (recommended)\\nmax every agent on HIGH (expensive)\\ntest every agent on LOW (development)\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"eco — per-agent tier (recommended)\", \"eco\"),\n\t\t\t\t\thuh.NewOption(\"max — every agent on HIGH (expensive)\", \"max\"),\n\t\t\t\t\thuh.NewOption(\"test — every agent on LOW (development)\", \"test\"),\n\t\t\t\t).\n\t\t\t\tValue(&profile),\n\t\t).Title(\"3 / 4 · Profile\"),\n\n\t\t// Step 4a: LangSmith toggle\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Enable LangSmith?\").\n\t\t\t\tDescription(\"LLM observability and trace collection\").\n\t\t\t\tAffirmative(\"Yes\").\n\t\t\t\tNegative(\"No\").\n\t\t\t\tValue(&useLangSmith),\n\t\t).Title(\"4 / 4 · Observability\"),\n\n\t\t// Step 4b: LangSmith key\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LangSmith API Key\").\n\t\t\t\tPlaceholder(\"lsv2_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&langSmithKey).\n\t\t\t\tValidate(nonEmpty),\n\t\t).Title(\"4 / 4 · LangSmith\").\n\t\t\tWithHideFunc(func() bool { return !useLangSmith }),\n\t).WithTheme(huh.ThemeFunc(ui.DecepticonTheme))\n\n\tif err := form.Run(); err != nil {\n\t\treturn fmt.Errorf(\"setup cancelled: %w\", err)\n\t}\n\n\t// huh.MultiSelect returns selected values in option order, not the\n\t// order the user toggled. Re-derive the priority by walking\n\t// methodOrder and keeping only what the user picked.\n\tpriority := make([]string, 0, len(methods))\n\tfor _, m := range methodOrder {\n\t\tif contains(methods, m) {\n\t\t\tpriority = append(priority, m)\n\t\t}\n\t}\n\n\tvalues := map[string]string{\n\t\t\"DECEPTICON_MODEL_PROFILE\": profile,\n\t\t\"DECEPTICON_AUTH_PRIORITY\": strings.Join(priority, \",\"),\n\t\t\"DECEPTICON_AUTH_CLAUDE_CODE\": boolStr(contains(methods, methodAnthropicOAuth)),\n\t}\n\n\tif anthropicKey != \"\" {\n\t\tvalues[\"ANTHROPIC_API_KEY\"] = anthropicKey\n\t}\n\tif openaiKey != \"\" {\n\t\tvalues[\"OPENAI_API_KEY\"] = openaiKey\n\t}\n\tif geminiKey != \"\" {\n\t\tvalues[\"GEMINI_API_KEY\"] = geminiKey\n\t}\n\tif minimaxKey != \"\" {\n\t\tvalues[\"MINIMAX_API_KEY\"] = minimaxKey\n\t}\n\n\tif useLangSmith && langSmithKey != \"\" {\n\t\tvalues[\"LANGSMITH_TRACING\"] = \"true\"\n\t\tvalues[\"LANGSMITH_API_KEY\"] = langSmithKey\n\t\tvalues[\"LANGSMITH_PROJECT\"] = \"decepticon\"\n\t}\n\n\tif err := config.WriteEnvFromEmbed(config.EnvPath(), values); err != nil {\n\t\treturn fmt.Errorf(\"write .env: %w\", err)\n\t}\n\n\t// Summary\n\tfmt.Println()\n\tfmt.Println(ui.Green.Render(\" ✓ Configuration saved\"))\n\tfmt.Println()\n\tfmt.Println(ui.Dim.Render(\" ┌──────────────────────────────────┐\"))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Methods \") + ui.Dim.Render(strings.Join(priority, \", \")))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Profile \") + ui.Dim.Render(profile))\n\tif useLangSmith {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" LangSmith \") + ui.Green.Render(\"enabled\"))\n\t}\n\tfmt.Println(ui.Dim.Render(\" │\"))\n\tfmt.Println(ui.Dim.Render(\" │ \") + ui.Dim.Render(config.EnvPath()))\n\tfmt.Println(ui.Dim.Render(\" └──────────────────────────────────┘\"))\n\tfmt.Println()\n\tui.DimText(\" Run 'decepticon' to start the platform\")\n\treturn nil\n}\n\nfunc contains(haystack []string, needle string) bool {\n\tfor _, s := range haystack {\n\t\tif s == needle {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc nonEmpty(s string) error {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn fmt.Errorf(\"value is required\")\n\t}\n\treturn nil\n}\n\nfunc boolStr(b bool) string {\n\tif b {\n\t\treturn \"true\"\n\t}\n\treturn \"false\"\n}\n"}, "ebc3430a8f818e41c3a4bc90dfb7c570a7b4286e": {"t": 1785755422.64581, "n": 6538, "h": "d88102c817bfaf8a", "c": "package cmd\n\nimport (\n\t\"fmt\"\n\n\t\"charm.land/huh/v2\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config\"\n\t\"github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar resetFlag bool\n\nvar onboardCmd = &cobra.Command{\n\tUse: \"onboard\",\n\tShort: \"Configure Decepticon (authentication, provider, model profile)\",\n\tRunE: runOnboard,\n}\n\nfunc init() {\n\tonboardCmd.Flags().BoolVar(&resetFlag, \"reset\", false, \"Reconfigure even if .env already exists\")\n\trootCmd.AddCommand(onboardCmd)\n}\n\nfunc runOnboard(cmd *cobra.Command, args []string) error {\n\tif config.EnvExists() && !resetFlag {\n\t\tui.Info(\".env already configured at \" + config.EnvPath())\n\t\tui.DimText(\"Run 'decepticon onboard --reset' to reconfigure\")\n\t\treturn nil\n\t}\n\n\tvar (\n\t\tauthMethod string\n\t\tllmProvider string\n\t\tapiKey string\n\t\tprofile string\n\t\tuseLangSmith bool\n\t\tlangSmithKey string\n\t)\n\n\tform := huh.NewForm(\n\t\t// Intro\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTitle(\"Decepticon Setup\").\n\t\t\t\tDescription(\"Configure authentication, LLM provider,\\nmodel profile, and observability.\\n\\nUse ↑↓ to navigate, Enter to confirm.\"),\n\t\t),\n\n\t\t// Step 1: Authentication method\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Authentication Method\").\n\t\t\t\tDescription(\"How should Decepticon authenticate with LLM providers?\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"API Key — Direct API access via x-api-key header\", \"api\"),\n\t\t\t\t\thuh.NewOption(\"OAuth — Subscription-based (Claude Code, Codex)\", \"auth\"),\n\t\t\t\t).\n\t\t\t\tValue(&authMethod),\n\t\t).Title(\"1 / 5 · Authentication\").\n\t\t\tDescription(\"Choose how to connect to LLM services\"),\n\n\t\t// Step 2: Provider selection\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"LLM Provider\").\n\t\t\t\tDescription(\"Which provider will power the agents?\").\n\t\t\t\tOptionsFunc(func() []huh.Option[string] {\n\t\t\t\t\tif authMethod == \"auth\" {\n\t\t\t\t\t\treturn []huh.Option[string]{\n\t\t\t\t\t\t\thuh.NewOption(\"Claude Code — Anthropic OAuth\", \"claude-code\"),\n\t\t\t\t\t\t\thuh.NewOption(\"Codex — coming soon\", \"codex\"),\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn []huh.Option[string]{\n\t\t\t\t\t\thuh.NewOption(\"Anthropic — Claude Opus / Sonnet / Haiku\", \"anthropic\"),\n\t\t\t\t\t\thuh.NewOption(\"OpenAI — GPT-5.4 / GPT-4.1\", \"openai\"),\n\t\t\t\t\t\thuh.NewOption(\"Google — Gemini 2.5 Flash\", \"google\"),\n\t\t\t\t\t\thuh.NewOption(\"MiniMax — M2.7\", \"minimax\"),\n\t\t\t\t\t}\n\t\t\t\t}, &authMethod).\n\t\t\t\tValue(&llmProvider),\n\t\t).Title(\"2 / 5 · Provider\").\n\t\t\tDescription(\"Select your primary LLM provider\"),\n\n\t\t// Step 3: API key input (only for api mode)\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitleFunc(func() string {\n\t\t\t\t\tswitch llmProvider {\n\t\t\t\t\tcase \"anthropic\":\n\t\t\t\t\t\treturn \"Anthropic API Key\"\n\t\t\t\t\tcase \"openai\":\n\t\t\t\t\t\treturn \"OpenAI API Key\"\n\t\t\t\t\tcase \"google\":\n\t\t\t\t\t\treturn \"Google API Key\"\n\t\t\t\t\tcase \"minimax\":\n\t\t\t\t\t\treturn \"MiniMax API Key\"\n\t\t\t\t\t}\n\t\t\t\t\treturn \"API Key\"\n\t\t\t\t}, &llmProvider).\n\t\t\t\tPlaceholderFunc(func() string {\n\t\t\t\t\tswitch llmProvider {\n\t\t\t\t\tcase \"anthropic\":\n\t\t\t\t\t\treturn \"sk-ant-...\"\n\t\t\t\t\tcase \"openai\":\n\t\t\t\t\t\treturn \"sk-...\"\n\t\t\t\t\tcase \"google\":\n\t\t\t\t\t\treturn \"AIza...\"\n\t\t\t\t\tcase \"minimax\":\n\t\t\t\t\t\treturn \"eyJ...\"\n\t\t\t\t\t}\n\t\t\t\t\treturn \"\"\n\t\t\t\t}, &llmProvider).\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&apiKey).\n\t\t\t\tValidate(func(s string) error {\n\t\t\t\t\tif s == \"\" {\n\t\t\t\t\t\treturn fmt.Errorf(\"API key is required\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"3 / 5 · Credentials\").\n\t\t\tDescription(\"Enter your provider API key\").\n\t\t\tWithHideFunc(func() bool {\n\t\t\t\treturn authMethod == \"auth\"\n\t\t\t}),\n\n\t\t// Step 4: Model profile\n\t\thuh.NewGroup(\n\t\t\thuh.NewSelect[string]().\n\t\t\t\tTitle(\"Model Profile\").\n\t\t\t\tDescription(\"Controls which models each agent tier uses\").\n\t\t\t\tOptions(\n\t\t\t\t\thuh.NewOption(\"eco — Opus + Sonnet + Haiku mix (recommended)\", \"eco\"),\n\t\t\t\t\thuh.NewOption(\"max — Opus everywhere (expensive)\", \"max\"),\n\t\t\t\t\thuh.NewOption(\"test — Haiku only (for development)\", \"test\"),\n\t\t\t\t).\n\t\t\t\tValue(&profile),\n\t\t).Title(\"4 / 5 · Performance\").\n\t\t\tDescription(\"Balance between cost and capability\"),\n\n\t\t// Step 5: LangSmith tracing\n\t\thuh.NewGroup(\n\t\t\thuh.NewConfirm().\n\t\t\t\tTitle(\"Enable LangSmith?\").\n\t\t\t\tDescription(\"LLM observability and trace collection\").\n\t\t\t\tAffirmative(\"Yes\").\n\t\t\t\tNegative(\"No\").\n\t\t\t\tValue(&useLangSmith),\n\t\t).Title(\"5 / 5 · Observability\").\n\t\t\tDescription(\"Optional tracing integration\"),\n\n\t\t// LangSmith API key (only when enabled)\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().\n\t\t\t\tTitle(\"LangSmith API Key\").\n\t\t\t\tPlaceholder(\"lsv2_...\").\n\t\t\t\tEchoMode(huh.EchoModePassword).\n\t\t\t\tValue(&langSmithKey).\n\t\t\t\tValidate(func(s string) error {\n\t\t\t\t\tif s == \"\" {\n\t\t\t\t\t\treturn fmt.Errorf(\"LangSmith API key is required\")\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}),\n\t\t).Title(\"5 / 5 · Observability\").\n\t\t\tDescription(\"Enter your LangSmith credentials\").\n\t\t\tWithHideFunc(func() bool {\n\t\t\t\treturn !useLangSmith\n\t\t\t}),\n\t).WithTheme(huh.ThemeFunc(ui.DecepticonTheme))\n\n\tif err := form.Run(); err != nil {\n\t\treturn fmt.Errorf(\"setup cancelled: %w\", err)\n\t}\n\n\t// Build values map\n\tvalues := map[string]string{\n\t\t\"DECEPTICON_MODEL_PROFILE\": profile,\n\t\t\"DECEPTICON_MODEL_PROVIDER\": authMethod,\n\t}\n\n\tif authMethod == \"api\" && apiKey != \"\" {\n\t\tswitch llmProvider {\n\t\tcase \"anthropic\":\n\t\t\tvalues[\"ANTHROPIC_API_KEY\"] = apiKey\n\t\tcase \"openai\":\n\t\t\tvalues[\"OPENAI_API_KEY\"] = apiKey\n\t\tcase \"google\":\n\t\t\tvalues[\"GOOGLE_API_KEY\"] = apiKey\n\t\tcase \"minimax\":\n\t\t\tvalues[\"MINIMAX_API_KEY\"] = apiKey\n\t\t}\n\t}\n\n\tif useLangSmith && langSmithKey != \"\" {\n\t\tvalues[\"LANGSMITH_TRACING\"] = \"true\"\n\t\tvalues[\"LANGSMITH_API_KEY\"] = langSmithKey\n\t\tvalues[\"LANGSMITH_PROJECT\"] = \"decepticon\"\n\t}\n\n\tif err := config.WriteEnvFromEmbed(config.EnvPath(), values); err != nil {\n\t\treturn fmt.Errorf(\"write .env: %w\", err)\n\t}\n\n\t// Summary\n\tfmt.Println()\n\tfmt.Println(ui.Green.Render(\" ✓ Configuration saved\"))\n\tfmt.Println()\n\tfmt.Println(ui.Dim.Render(\" ┌──────────────────────────────────┐\"))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Auth \") + ui.Dim.Render(authMethod))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Provider \") + ui.Dim.Render(llmProvider))\n\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" Profile \") + ui.Dim.Render(profile))\n\tif useLangSmith {\n\t\tfmt.Println(ui.Dim.Render(\" │\") + ui.Cyan.Render(\" LangSmith \") + ui.Green.Render(\"enabled\"))\n\t}\n\tfmt.Println(ui.Dim.Render(\" │\"))\n\tfmt.Println(ui.Dim.Render(\" │ \") + ui.Dim.Render(config.EnvPath()))\n\tfmt.Println(ui.Dim.Render(\" └──────────────────────────────────┘\"))\n\tfmt.Println()\n\tui.DimText(\" Run 'decepticon' to start the platform\")\n\treturn nil\n}\n"}}, "packages/decepticon/decepticon/llm/factory.py": {"42b918525fb79065e730da3da997bffb33fcd414": {"t": 1785746553.5758297, "n": 74853, "h": "1caf916e5bfa0b98", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nimport re\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any, Literal\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AGENT_TIERS,\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelAssignment,\n ModelProfile,\n ProxyConfig,\n Tier,\n resolve_chain,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\n# Output-token cap for every model created through this factory.\n# We must set one explicitly: the LiteLLM proxy applies the Anthropic default\n# of 4096 when the client sends no ``max_tokens``, and a single large\n# ``write_file`` tool call (e.g. a full finding report or a recon target model)\n# then truncates mid-argument — the tool-call JSON never closes, so the\n# ``content`` arg is dropped and the write fails. The cap is per-model: we hand\n# each model its OWN max-output ceiling so a big deliverable completes in one\n# call. It is a ceiling, not a forced value — short replies cost nothing extra.\n# Values above 64k require the streaming API path (the SDK already streams).\n# Authoritative caps (see ``_model_max_output_tokens``): Claude Opus 4.x and\n# Sonnet (4.6/5) = 128000; Claude Haiku 4.5 = 64000. Unknown models fall back\n# to the safe 64k default. Override with ``DECEPTICON_LLM_MAX_TOKENS`` (an\n# explicit value wins over the per-model resolution).\nDEFAULT_LLM_MAX_TOKENS = 64000\nLLM_MAX_TOKENS_ENV = \"DECEPTICON_LLM_MAX_TOKENS\"\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n# `.env.example` documents the pydantic-settings nested form\n# (``DECEPTICON_LLM__TIMEOUT``) because that's what the rest of the\n# settings tree uses (``LLMConfig.timeout`` → httpx transport). The\n# whole-coroutine guard below is a separate piece of plumbing — but\n# from the user's point of view there is only one \"LLM timeout\" knob,\n# so we honor the documented name too. Precedence:\n# ``DECEPTICON_LLM_TIMEOUT_SECONDS`` (explicit, whole-coroutine) >\n# ``DECEPTICON_LLM__TIMEOUT`` (the published env knob) > default.\nLLM_TIMEOUT_ENV_ALIAS = \"DECEPTICON_LLM__TIMEOUT\"\n\n\ndef _model_max_output_tokens(model: str) -> int:\n \"\"\"Return ``model``'s own max-output-token ceiling.\n\n Match on the model slug suffix (last path segment) so every namespace we\n route through resolves the same — ``anthropic/claude-opus-4-8``,\n ``auth/claude-opus-4-8``, ``openrouter/anthropic/claude-sonnet-4-6``.\n Opus 4.x and Sonnet (4.6/5) support 128000 output tokens; Haiku 4.5\n supports 64000. Unknown / non-Claude models fall back to the safe 64k\n default rather than an over-large value the upstream might reject.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n if \"opus\" in slug or \"sonnet\" in slug:\n return 128000\n if \"haiku\" in slug:\n return 64000\n return DEFAULT_LLM_MAX_TOKENS\n\n\ndef _resolve_max_tokens(model: str) -> int:\n \"\"\"Output-token cap for a factory-created model.\n\n ``DECEPTICON_LLM_MAX_TOKENS`` (explicit override) wins when set to a\n positive int; otherwise the model's own max-output ceiling is used so a\n large single-call deliverable (finding report, recon model) never truncates\n at the proxy's 4096 default.\n \"\"\"\n raw = os.environ.get(LLM_MAX_TOKENS_ENV)\n if raw:\n try:\n value = int(raw)\n except ValueError:\n value = 0\n if value > 0:\n return value\n return _model_max_output_tokens(model)\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env >\n ``DECEPTICON_LLM__TIMEOUT`` env (the pydantic-settings nested form\n advertised in ``.env.example``) > default ``600``. The alias is what\n users actually set when following the published docs — without it,\n changing the documented knob silently has no effect on the whole-\n coroutine guard and long generations still trip the 600 s default\n (see issue #540).\n\n Rejects non-positive or non-numeric env values with ``ValueError``\n so misconfiguration fails loudly rather than silently disabling the\n guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n source = LLM_TIMEOUT_ENV\n if not raw:\n raw = os.getenv(LLM_TIMEOUT_ENV_ALIAS, \"\").strip()\n source = LLM_TIMEOUT_ENV_ALIAS\n if raw:\n try:\n value = float(raw)\n except ValueError as exc:\n raise ValueError(f\"{source} must be a number (got {raw!r})\") from exc\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{source} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Placed\n # after the first-party API providers and before the local endpoints:\n # a hosted gateway is a reasonable fallback but shouldn't preempt a\n # direct vendor key the user also configured.\n AuthMethod.OPENCODE_API,\n AuthMethod.VERCEL_GATEWAY_API,\n AuthMethod.ZENMUX_API,\n AuthMethod.NANOGPT_API,\n AuthMethod.VENICE_API,\n AuthMethod.SYNTHETIC_API,\n AuthMethod.HUGGINGFACE_API,\n AuthMethod.QIANFAN_API,\n AuthMethod.CLOUDFLARE_GATEWAY_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Each is\n # detected by the presence of a non-placeholder bearer key. The base\n # URL is fixed per gateway (see config/litellm.yaml) except Cloudflare,\n # whose per-account CLOUDFLARE_AI_GATEWAY_API_BASE must also be set —\n # documented in .env.example; a key without the base 404s at call time.\n AuthMethod.OPENCODE_API: \"OPENCODE_API_KEY\",\n AuthMethod.VERCEL_GATEWAY_API: \"VERCEL_AI_GATEWAY_API_KEY\",\n AuthMethod.HUGGINGFACE_API: \"HF_TOKEN\",\n AuthMethod.VENICE_API: \"VENICE_API_KEY\",\n AuthMethod.NANOGPT_API: \"NANOGPT_API_KEY\",\n AuthMethod.SYNTHETIC_API: \"SYNTHETIC_API_KEY\",\n AuthMethod.ZENMUX_API: \"ZENMUX_API_KEY\",\n AuthMethod.QIANFAN_API: \"QIANFAN_API_KEY\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"CLOUDFLARE_AI_GATEWAY_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n# Env vars each LiteLLM OAuth handler accepts as a credential *in place of*\n# its on-disk token file (see config/*_handler.py ``_load_tokens`` /\n# ``_env_override_tokens`` / ``_resolve_source_token``). A non-empty value in\n# any of these means the handler can authenticate without a file, so the\n# method must still count as configured. Codex (OPENAI_OAUTH) exposes no\n# env-token override — only the file-path env vars already covered by\n# ``_OAUTH_CREDENTIAL_PATHS`` — so it is intentionally absent here.\n_OAUTH_ENV_CREDENTIALS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\"ANTHROPIC_OAUTH_TOKEN\",),\n AuthMethod.GOOGLE_OAUTH: (\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"),\n AuthMethod.COPILOT_OAUTH: (\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"),\n AuthMethod.GROK_OAUTH: (\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"),\n AuthMethod.PERPLEXITY_OAUTH: (\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Any of ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint),\n ``OLLAMA_CLOUD_MODEL``, or a cloud API key\n (``OLLAMA_CLOUD_API_KEY`` / ``OLLAMA_API_KEY``) is enough to opt in.\n The key is included so a user who pasted only their key into\n `decepticon onboard` (relying on the default base + model) is still\n detected as a cloud user instead of silently falling through.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_API_KEY\", \"\").strip()\n or os.getenv(\"OLLAMA_API_KEY\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _oauth_env_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if any env var the handler accepts as a credential is set.\n\n Env-only OAuth setups (e.g. ``GEMINI_ACCESS_TOKEN`` with no token file)\n are valid: the LiteLLM handler authenticates straight from the env. Such\n a method must not be dropped from the fallback chain just because no file\n exists on disk.\n \"\"\"\n return any(os.getenv(env_var, \"\").strip() for env_var in _OAUTH_ENV_CREDENTIALS.get(method, ()))\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need the boolean intent AND a usable credential. The\n # credential may live in a token file OR in the handler's env-var\n # override — env-only setups are valid and must not be dropped.\n if not _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")):\n return False\n return _oauth_credentials_present(method) or _oauth_env_credentials_present(method)\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.OPENCODE_API: \"OpenCode Zen — API key\",\n AuthMethod.VERCEL_GATEWAY_API: \"Vercel AI Gateway — API key\",\n AuthMethod.HUGGINGFACE_API: \"Hugging Face Router — token\",\n AuthMethod.VENICE_API: \"Venice AI — API key\",\n AuthMethod.NANOGPT_API: \"NanoGPT — API key\",\n AuthMethod.SYNTHETIC_API: \"Synthetic — API key\",\n AuthMethod.ZENMUX_API: \"ZenMux — API key\",\n AuthMethod.QIANFAN_API: \"Baidu Qianfan (ERNIE) — API key\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"Cloudflare AI Gateway — API key + base URL\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n if _oauth_env_credentials_present(method):\n return f\"{flag}=true and credential present (env)\"\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n env_vars = \", \".join(_OAUTH_ENV_CREDENTIALS.get(method, ()))\n sources = paths or \"token\"\n if env_vars:\n sources = f\"{sources} or {env_vars}\"\n return f\"{flag}=true but no credential ({sources})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n result = super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n _log_served_model(self.model_name, result)\n return result\n\n async def ainvoke(self, *args, **kwargs):\n # Resolve the timeout *before* creating the request coroutine. A\n # misconfigured ``DECEPTICON_LLM_TIMEOUT_SECONDS`` raises ValueError;\n # if that were evaluated as the second argument to\n # ``call_with_timeout`` the ``super().ainvoke(...)`` coroutine would\n # already exist and leak un-awaited (\"coroutine was never awaited\").\n timeout = _resolve_llm_timeout_seconds()\n try:\n result = await call_with_timeout(super().ainvoke(*args, **kwargs), timeout)\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n _log_served_model(self.model_name, result)\n return result\n\n\ndef _log_served_model(requested: str, result: object) -> None:\n \"\"\"Best-effort attribution log: which provider/model the LiteLLM proxy\n actually routed to. When fallback fires (primary -> fallback) the\n requested model id and the served model id diverge — surfacing the\n delta lets operators trace cost/perf/debug back to the real upstream.\n\n Never raise from this path: attribution is observability, not control\n flow. Any extraction failure is swallowed silently.\n \"\"\"\n try:\n metadata = getattr(result, \"response_metadata\", None) or {}\n served = (\n metadata.get(\"model_name\")\n or metadata.get(\"model\")\n or getattr(result, \"model_name\", None)\n )\n if served and served != requested:\n log.info(\"llm.attribution requested=%s served=%s\", requested, served)\n elif served:\n log.debug(\"llm.attribution requested=%s served=%s\", requested, served)\n except Exception:\n pass\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n # Resolve the timeout before creating the request coroutine so a\n # misconfigured timeout env raises ValueError without leaving the\n # ``super()._agenerate(...)`` coroutine un-awaited.\n timeout = _resolve_llm_timeout_seconds()\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n timeout,\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\n# Patterns matching secret-shaped substrings that providers may echo back\n# inside HTTP error bodies. ``str(exc)`` for LiteLLM/openai errors is built\n# from that body, so it can reflect the Authorization/x-api-key/Bearer\n# credential we sent. Each pattern below scrubs one shape before the text is\n# interpolated into a user-facing RuntimeError that lands in CLI output/logs.\n_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (\n # \"Bearer <token>\" auth scheme (RFC 6750), case-insensitive.\n (re.compile(r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"), \"Bearer [REDACTED]\"),\n # Provider key prefixes: Anthropic sk-ant-* and OpenAI-style sk-* (any\n # remaining long token after the prefix).\n (re.compile(r\"\\bsk-(?:ant-)?[\\w.\\-]{8,}\"), \"[REDACTED]\"),\n # Header/field values for authorization / api_key / x-api-key, whether\n # JSON (\"authorization\": \"...\") or kwarg (api_key=...). Keeps the key\n # name so the guidance stays readable; only the value is scrubbed.\n (\n re.compile(\n r\"(?i)(['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*)\"\n r\"(['\\\"]?)[^'\\\"\\s,}]+\\2\"\n ),\n r\"\\1\\2[REDACTED]\\2\",\n ),\n # Generic long opaque tokens (>=24 chars of key-ish alphabet) that survive\n # the targeted passes above — e.g. provider keys without an sk- prefix.\n (re.compile(r\"\\b[A-Za-z0-9_\\-]{24,}\\b\"), \"[REDACTED]\"),\n)\n\n# High-confidence credential shapes used to decide whether an *unclassified*\n# provider error would leak a secret if its text were re-raised verbatim.\n# Deliberately excludes the broad ``>=24-char`` catch-all from\n# ``_SECRET_PATTERNS`` (that one also matches long model ids / hashes and\n# would over-trigger), keeping only the unambiguous ``Bearer``/``sk-``/\n# ``authorization:`` shapes.\n_CREDENTIAL_SHAPE = re.compile(\n r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"\n r\"|\\bsk-(?:ant-)?[\\w.\\-]{8,}\"\n r\"|['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*['\\\"]?[^'\\\"\\s,}]+\"\n)\n\n\ndef _redact_url_credentials(url: str) -> str:\n \"\"\"Strip ``user:pass@`` userinfo from a URL before logging.\n\n Operators may configure the proxy URL with embedded credentials\n (``http://user:pass@litellm:4000``); those must not reach INFO logs.\n \"\"\"\n from urllib.parse import urlsplit, urlunsplit\n\n try:\n parts = urlsplit(url)\n except ValueError:\n return url\n if not parts.scheme or \"@\" not in parts.netloc:\n return url\n host = parts.hostname or \"\"\n if parts.port is not None:\n host = f\"{host}:{parts.port}\"\n return urlunsplit((parts.scheme, f\"***@{host}\", parts.path, parts.query, parts.fragment))\n\n\ndef _redact_secrets(text: str) -> str:\n \"\"\"Scrub credential-shaped substrings from upstream error text.\n\n LiteLLM/openai surface the provider's raw HTTP response body via\n ``str(exc)``; that body can echo the ``Authorization``/``x-api-key``/\n ``Bearer`` value we sent. Redacting here keeps the actionable guidance\n (status code, model id, human hint) intact while preventing the secret\n from leaking into CLI output and logs.\n \"\"\"\n for pattern, replacement in _SECRET_PATTERNS:\n text = pattern.sub(replacement, text)\n return text\n\n\nProviderErrorClass = Literal[\"retryable\", \"fatal\"]\n\n# HTTP status → class. 408/425/429 + 5xx are worth a retry / fallback hop;\n# 4xx auth/config (400/401/403/404) won't be fixed by another attempt.\n_RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})\n_FATAL_STATUS = frozenset({400, 401, 403, 404})\n\n# Last-resort message scrape when the exception lacks ``status_code`` —\n# LiteLLM/openai surface the upstream status only in the body text.\n_STATUS_IN_MSG = re.compile(r\"(?:error\\s*code|status(?:_code)?|http)\\D{0,8}(\\d{3})\", re.I)\n\n\ndef _classify_provider_error(exc: BaseException) -> ProviderErrorClass:\n \"\"\"Tag a provider exception as ``retryable`` or ``fatal``.\n\n Inspection order (cheapest signal first):\n 1. Our own ``LLMTimeoutError`` and httpx transport errors → retryable.\n 2. ``status_code`` attribute (openai.APIStatusError shape).\n 3. ``response.status_code`` (httpx.HTTPStatusError shape).\n 4. Regex over ``str(exc)`` — LiteLLM nests upstream status in the body.\n\n Default for unrecognised shapes is ``retryable`` so we preserve today's\n behavior (LiteLLM ``num_retries`` keeps spinning). Only flip to ``fatal``\n on a positive 4xx signal.\n \"\"\"\n if isinstance(exc, LLMTimeoutError):\n return \"retryable\"\n if isinstance(exc, (httpx.TransportError, httpx.TimeoutException)):\n return \"retryable\"\n\n status = getattr(exc, \"status_code\", None)\n if status is None:\n response = getattr(exc, \"response\", None)\n status = getattr(response, \"status_code\", None)\n if isinstance(status, int):\n if status in _FATAL_STATUS:\n return \"fatal\"\n if status in _RETRYABLE_STATUS:\n return \"retryable\"\n\n match = _STATUS_IN_MSG.search(str(exc))\n if match is not None:\n code = int(match.group(1))\n if code in _FATAL_STATUS:\n return \"fatal\"\n if code in _RETRYABLE_STATUS:\n return \"retryable\"\n\n return \"retryable\"\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {_redact_secrets(str(exc))}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n fatal_prefix = (\n \"non-retryable provider error: \" if _classify_provider_error(exc) == \"fatal\" else \"\"\n )\n # Match on the raw text (status codes / keywords are not secret-shaped),\n # but interpolate the scrubbed copy so an echoed credential never reaches\n # the user-facing message — see _redact_secrets.\n safe_msg = _redact_secrets(msg)\n status = getattr(exc, \"status_code\", None)\n if status is None:\n status = getattr(getattr(exc, \"response\", None), \"status_code\", None)\n status_match = _STATUS_IN_MSG.search(msg)\n is_bad_request = (\n \"badrequest\" in err_type.lower()\n or status == 400\n or (status_match is not None and int(status_match.group(1)) == 400)\n )\n\n if is_bad_request and \"invalid model name\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' is not available through the LiteLLM \"\n f\"proxy. Verify its static route in config/litellm.yaml or dynamic environment \"\n f\"configuration (DECEPTICON_MODEL*, DECEPTICON_LITELLM_MODELS, \"\n f\"CUSTOM_OPENAI_*, or OLLAMA_MODEL). Restart the proxy, inspect its startup \"\n f\"logs, and query /v1/models for the models available to your key.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n if is_bad_request:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {safe_msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if (\n \"permissiondenied\" in err_type.lower()\n or \"forbidden\" in err_type.lower()\n or \"code: 403\" in msg_lower\n ):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' refused access (403). The \"\n f\"provider accepted your credentials but forbids this model \"\n f\"(region lock, plan tier, or org policy). Verify your account \"\n f\"can use that model, or route to another method via \"\n f\"DECEPTICON_AUTH_PRIORITY.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n # Final safety net for *unclassified* provider errors. None of the\n # branches above matched, so the caller's trailing ``raise`` would\n # re-raise ``exc`` verbatim. If its text carries a credential-shaped\n # substring (e.g. an echoed Authorization header on a 500/422/region\n # error we don't special-case), re-raising raw would leak the secret\n # into CLI output and logs. Re-wrap with the scrubbed copy. Errors with\n # no credential shape fall through untouched so their original traceback\n # is preserved (see test_unmatched_error_passes_through).\n if _CREDENTIAL_SHAPE.search(msg):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' failed with an unclassified \"\n f\"provider error. Underlying: {safe_msg}\"\n ) from exc\n\n\ndef _tiers_required(profile: ModelProfile) -> dict[Tier, list[str]]:\n if profile == ModelProfile.MAX:\n return {Tier.HIGH: list(AGENT_TIERS)}\n if profile == ModelProfile.TEST:\n return {Tier.LOW: list(AGENT_TIERS)}\n grouped: dict[Tier, list[str]] = {}\n for role, tier in AGENT_TIERS.items():\n grouped.setdefault(tier, []).append(role)\n return grouped\n\n\ndef _validate_chain_coverage(credentials: Credentials, profile: ModelProfile) -> None:\n \"\"\"Fail fast when ``credentials`` resolve to an EMPTY chain for any\n tier the factory must serve under ``profile``.\n\n ``LLMModelMapping.from_credentials_and_profile`` silently skips a role\n whose resolved chain is empty (no configured AuthMethod has a\n ``(method, tier)`` entry in ``METHOD_MODELS``). Downstream this surfaces\n as a confusing ``KeyError`` at ``get_model`` time or a primary-less\n ``ModelFallbackMiddleware([])``. Surface it once, here, with the\n offending tier(s) + the configured inventory so the operator can fix\n config immediately rather than chase a runtime failure.\n \"\"\"\n empty: list[tuple[Tier, list[str]]] = [\n (tier, roles)\n for tier, roles in _tiers_required(profile).items()\n if not resolve_chain(tier, credentials)\n ]\n if not empty:\n return\n details = \"; \".join(\n f\"{tier.value} (roles: {', '.join(sorted(roles))})\" for tier, roles in empty\n )\n inventory = \", \".join(m.value for m in credentials.methods) or \"<none>\"\n tier_list = \", \".join(tier.value for tier, _ in empty)\n raise ValueError(\n f\"LLMFactory: no model available for tier(s) [{tier_list}] under \"\n f\"profile {profile.value!r}. Empty chains: {details}. Configured \"\n f\"credentials: [{inventory}]. Add a credential whose AuthMethod \"\n f\"has an entry for the listed tier(s) in METHOD_MODELS \"\n f\"(packages/decepticon-core/decepticon_core/types/llm.py), or \"\n f\"adjust DECEPTICON_AUTH_PRIORITY / DECEPTICON_MODEL_PROFILE.\"\n )\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n raw_profile = profile if profile is not None else self._resolve_profile()\n resolved_profile = ModelProfile(raw_profile)\n _validate_chain_coverage(creds, resolved_profile)\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _compose_assignment(role: str, assignment: ModelAssignment) -> ModelAssignment:\n \"\"\"Apply a composed per-role model override to ``assignment``.\n\n Precedence (env > ``PluginBundle.models`` > tier default) is owned\n by ``decepticon.agents.build.resolve_role_model``. The displaced\n tier primary is kept as the head of the fallback chain so a bad\n override still degrades to the vetted default. Applied per get, so\n it also re-tiers custom plugin roles that resolve via a fallback\n role rather than living in ``AGENT_TIERS``. Imported lazily —\n build → factory is a normal edge; the reverse must stay absent.\n \"\"\"\n from decepticon.agents.build import resolve_role_model\n\n override = resolve_role_model(role)\n if not override or override == assignment.primary:\n return assignment\n return ModelAssignment(\n primary=override,\n fallbacks=[assignment.primary, *(m for m in assignment.fallbacks if m != override)],\n temperature=assignment.temperature,\n )\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. a downstream\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as a downstream ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._compose_assignment(\n role, self._router.get_assignment(role, default_role=default_role)\n )\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n _redact_url_credentials(self._proxy.url),\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._compose_assignment(\n role, self._router.get_assignment(role, default_role=default_role)\n )\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n # Per-model cap so large single-call deliverables (finding reports,\n # recon target models) don't truncate at the proxy's 4096 default.\n \"max_tokens\": _resolve_max_tokens(model),\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "ccb8de60e4aa310acef989ca7c8e4927f6087dbd": {"t": 1785755382.2485175, "n": 74853, "h": "1caf916e5bfa0b98", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nimport re\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any, Literal\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AGENT_TIERS,\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelAssignment,\n ModelProfile,\n ProxyConfig,\n Tier,\n resolve_chain,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\n# Output-token cap for every model created through this factory.\n# We must set one explicitly: the LiteLLM proxy applies the Anthropic default\n# of 4096 when the client sends no ``max_tokens``, and a single large\n# ``write_file`` tool call (e.g. a full finding report or a recon target model)\n# then truncates mid-argument — the tool-call JSON never closes, so the\n# ``content`` arg is dropped and the write fails. The cap is per-model: we hand\n# each model its OWN max-output ceiling so a big deliverable completes in one\n# call. It is a ceiling, not a forced value — short replies cost nothing extra.\n# Values above 64k require the streaming API path (the SDK already streams).\n# Authoritative caps (see ``_model_max_output_tokens``): Claude Opus 4.x and\n# Sonnet (4.6/5) = 128000; Claude Haiku 4.5 = 64000. Unknown models fall back\n# to the safe 64k default. Override with ``DECEPTICON_LLM_MAX_TOKENS`` (an\n# explicit value wins over the per-model resolution).\nDEFAULT_LLM_MAX_TOKENS = 64000\nLLM_MAX_TOKENS_ENV = \"DECEPTICON_LLM_MAX_TOKENS\"\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n# `.env.example` documents the pydantic-settings nested form\n# (``DECEPTICON_LLM__TIMEOUT``) because that's what the rest of the\n# settings tree uses (``LLMConfig.timeout`` → httpx transport). The\n# whole-coroutine guard below is a separate piece of plumbing — but\n# from the user's point of view there is only one \"LLM timeout\" knob,\n# so we honor the documented name too. Precedence:\n# ``DECEPTICON_LLM_TIMEOUT_SECONDS`` (explicit, whole-coroutine) >\n# ``DECEPTICON_LLM__TIMEOUT`` (the published env knob) > default.\nLLM_TIMEOUT_ENV_ALIAS = \"DECEPTICON_LLM__TIMEOUT\"\n\n\ndef _model_max_output_tokens(model: str) -> int:\n \"\"\"Return ``model``'s own max-output-token ceiling.\n\n Match on the model slug suffix (last path segment) so every namespace we\n route through resolves the same — ``anthropic/claude-opus-4-8``,\n ``auth/claude-opus-4-8``, ``openrouter/anthropic/claude-sonnet-4-6``.\n Opus 4.x and Sonnet (4.6/5) support 128000 output tokens; Haiku 4.5\n supports 64000. Unknown / non-Claude models fall back to the safe 64k\n default rather than an over-large value the upstream might reject.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n if \"opus\" in slug or \"sonnet\" in slug:\n return 128000\n if \"haiku\" in slug:\n return 64000\n return DEFAULT_LLM_MAX_TOKENS\n\n\ndef _resolve_max_tokens(model: str) -> int:\n \"\"\"Output-token cap for a factory-created model.\n\n ``DECEPTICON_LLM_MAX_TOKENS`` (explicit override) wins when set to a\n positive int; otherwise the model's own max-output ceiling is used so a\n large single-call deliverable (finding report, recon model) never truncates\n at the proxy's 4096 default.\n \"\"\"\n raw = os.environ.get(LLM_MAX_TOKENS_ENV)\n if raw:\n try:\n value = int(raw)\n except ValueError:\n value = 0\n if value > 0:\n return value\n return _model_max_output_tokens(model)\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env >\n ``DECEPTICON_LLM__TIMEOUT`` env (the pydantic-settings nested form\n advertised in ``.env.example``) > default ``600``. The alias is what\n users actually set when following the published docs — without it,\n changing the documented knob silently has no effect on the whole-\n coroutine guard and long generations still trip the 600 s default\n (see issue #540).\n\n Rejects non-positive or non-numeric env values with ``ValueError``\n so misconfiguration fails loudly rather than silently disabling the\n guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n source = LLM_TIMEOUT_ENV\n if not raw:\n raw = os.getenv(LLM_TIMEOUT_ENV_ALIAS, \"\").strip()\n source = LLM_TIMEOUT_ENV_ALIAS\n if raw:\n try:\n value = float(raw)\n except ValueError as exc:\n raise ValueError(f\"{source} must be a number (got {raw!r})\") from exc\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{source} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Placed\n # after the first-party API providers and before the local endpoints:\n # a hosted gateway is a reasonable fallback but shouldn't preempt a\n # direct vendor key the user also configured.\n AuthMethod.OPENCODE_API,\n AuthMethod.VERCEL_GATEWAY_API,\n AuthMethod.ZENMUX_API,\n AuthMethod.NANOGPT_API,\n AuthMethod.VENICE_API,\n AuthMethod.SYNTHETIC_API,\n AuthMethod.HUGGINGFACE_API,\n AuthMethod.QIANFAN_API,\n AuthMethod.CLOUDFLARE_GATEWAY_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Each is\n # detected by the presence of a non-placeholder bearer key. The base\n # URL is fixed per gateway (see config/litellm.yaml) except Cloudflare,\n # whose per-account CLOUDFLARE_AI_GATEWAY_API_BASE must also be set —\n # documented in .env.example; a key without the base 404s at call time.\n AuthMethod.OPENCODE_API: \"OPENCODE_API_KEY\",\n AuthMethod.VERCEL_GATEWAY_API: \"VERCEL_AI_GATEWAY_API_KEY\",\n AuthMethod.HUGGINGFACE_API: \"HF_TOKEN\",\n AuthMethod.VENICE_API: \"VENICE_API_KEY\",\n AuthMethod.NANOGPT_API: \"NANOGPT_API_KEY\",\n AuthMethod.SYNTHETIC_API: \"SYNTHETIC_API_KEY\",\n AuthMethod.ZENMUX_API: \"ZENMUX_API_KEY\",\n AuthMethod.QIANFAN_API: \"QIANFAN_API_KEY\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"CLOUDFLARE_AI_GATEWAY_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n# Env vars each LiteLLM OAuth handler accepts as a credential *in place of*\n# its on-disk token file (see config/*_handler.py ``_load_tokens`` /\n# ``_env_override_tokens`` / ``_resolve_source_token``). A non-empty value in\n# any of these means the handler can authenticate without a file, so the\n# method must still count as configured. Codex (OPENAI_OAUTH) exposes no\n# env-token override — only the file-path env vars already covered by\n# ``_OAUTH_CREDENTIAL_PATHS`` — so it is intentionally absent here.\n_OAUTH_ENV_CREDENTIALS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\"ANTHROPIC_OAUTH_TOKEN\",),\n AuthMethod.GOOGLE_OAUTH: (\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"),\n AuthMethod.COPILOT_OAUTH: (\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"),\n AuthMethod.GROK_OAUTH: (\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"),\n AuthMethod.PERPLEXITY_OAUTH: (\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Any of ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint),\n ``OLLAMA_CLOUD_MODEL``, or a cloud API key\n (``OLLAMA_CLOUD_API_KEY`` / ``OLLAMA_API_KEY``) is enough to opt in.\n The key is included so a user who pasted only their key into\n `decepticon onboard` (relying on the default base + model) is still\n detected as a cloud user instead of silently falling through.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_API_KEY\", \"\").strip()\n or os.getenv(\"OLLAMA_API_KEY\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _oauth_env_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if any env var the handler accepts as a credential is set.\n\n Env-only OAuth setups (e.g. ``GEMINI_ACCESS_TOKEN`` with no token file)\n are valid: the LiteLLM handler authenticates straight from the env. Such\n a method must not be dropped from the fallback chain just because no file\n exists on disk.\n \"\"\"\n return any(os.getenv(env_var, \"\").strip() for env_var in _OAUTH_ENV_CREDENTIALS.get(method, ()))\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need the boolean intent AND a usable credential. The\n # credential may live in a token file OR in the handler's env-var\n # override — env-only setups are valid and must not be dropped.\n if not _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")):\n return False\n return _oauth_credentials_present(method) or _oauth_env_credentials_present(method)\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.OPENCODE_API: \"OpenCode Zen — API key\",\n AuthMethod.VERCEL_GATEWAY_API: \"Vercel AI Gateway — API key\",\n AuthMethod.HUGGINGFACE_API: \"Hugging Face Router — token\",\n AuthMethod.VENICE_API: \"Venice AI — API key\",\n AuthMethod.NANOGPT_API: \"NanoGPT — API key\",\n AuthMethod.SYNTHETIC_API: \"Synthetic — API key\",\n AuthMethod.ZENMUX_API: \"ZenMux — API key\",\n AuthMethod.QIANFAN_API: \"Baidu Qianfan (ERNIE) — API key\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"Cloudflare AI Gateway — API key + base URL\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n if _oauth_env_credentials_present(method):\n return f\"{flag}=true and credential present (env)\"\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n env_vars = \", \".join(_OAUTH_ENV_CREDENTIALS.get(method, ()))\n sources = paths or \"token\"\n if env_vars:\n sources = f\"{sources} or {env_vars}\"\n return f\"{flag}=true but no credential ({sources})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n result = super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n _log_served_model(self.model_name, result)\n return result\n\n async def ainvoke(self, *args, **kwargs):\n # Resolve the timeout *before* creating the request coroutine. A\n # misconfigured ``DECEPTICON_LLM_TIMEOUT_SECONDS`` raises ValueError;\n # if that were evaluated as the second argument to\n # ``call_with_timeout`` the ``super().ainvoke(...)`` coroutine would\n # already exist and leak un-awaited (\"coroutine was never awaited\").\n timeout = _resolve_llm_timeout_seconds()\n try:\n result = await call_with_timeout(super().ainvoke(*args, **kwargs), timeout)\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n _log_served_model(self.model_name, result)\n return result\n\n\ndef _log_served_model(requested: str, result: object) -> None:\n \"\"\"Best-effort attribution log: which provider/model the LiteLLM proxy\n actually routed to. When fallback fires (primary -> fallback) the\n requested model id and the served model id diverge — surfacing the\n delta lets operators trace cost/perf/debug back to the real upstream.\n\n Never raise from this path: attribution is observability, not control\n flow. Any extraction failure is swallowed silently.\n \"\"\"\n try:\n metadata = getattr(result, \"response_metadata\", None) or {}\n served = (\n metadata.get(\"model_name\")\n or metadata.get(\"model\")\n or getattr(result, \"model_name\", None)\n )\n if served and served != requested:\n log.info(\"llm.attribution requested=%s served=%s\", requested, served)\n elif served:\n log.debug(\"llm.attribution requested=%s served=%s\", requested, served)\n except Exception:\n pass\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n # Resolve the timeout before creating the request coroutine so a\n # misconfigured timeout env raises ValueError without leaving the\n # ``super()._agenerate(...)`` coroutine un-awaited.\n timeout = _resolve_llm_timeout_seconds()\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n timeout,\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\n# Patterns matching secret-shaped substrings that providers may echo back\n# inside HTTP error bodies. ``str(exc)`` for LiteLLM/openai errors is built\n# from that body, so it can reflect the Authorization/x-api-key/Bearer\n# credential we sent. Each pattern below scrubs one shape before the text is\n# interpolated into a user-facing RuntimeError that lands in CLI output/logs.\n_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (\n # \"Bearer <token>\" auth scheme (RFC 6750), case-insensitive.\n (re.compile(r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"), \"Bearer [REDACTED]\"),\n # Provider key prefixes: Anthropic sk-ant-* and OpenAI-style sk-* (any\n # remaining long token after the prefix).\n (re.compile(r\"\\bsk-(?:ant-)?[\\w.\\-]{8,}\"), \"[REDACTED]\"),\n # Header/field values for authorization / api_key / x-api-key, whether\n # JSON (\"authorization\": \"...\") or kwarg (api_key=...). Keeps the key\n # name so the guidance stays readable; only the value is scrubbed.\n (\n re.compile(\n r\"(?i)(['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*)\"\n r\"(['\\\"]?)[^'\\\"\\s,}]+\\2\"\n ),\n r\"\\1\\2[REDACTED]\\2\",\n ),\n # Generic long opaque tokens (>=24 chars of key-ish alphabet) that survive\n # the targeted passes above — e.g. provider keys without an sk- prefix.\n (re.compile(r\"\\b[A-Za-z0-9_\\-]{24,}\\b\"), \"[REDACTED]\"),\n)\n\n# High-confidence credential shapes used to decide whether an *unclassified*\n# provider error would leak a secret if its text were re-raised verbatim.\n# Deliberately excludes the broad ``>=24-char`` catch-all from\n# ``_SECRET_PATTERNS`` (that one also matches long model ids / hashes and\n# would over-trigger), keeping only the unambiguous ``Bearer``/``sk-``/\n# ``authorization:`` shapes.\n_CREDENTIAL_SHAPE = re.compile(\n r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"\n r\"|\\bsk-(?:ant-)?[\\w.\\-]{8,}\"\n r\"|['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*['\\\"]?[^'\\\"\\s,}]+\"\n)\n\n\ndef _redact_url_credentials(url: str) -> str:\n \"\"\"Strip ``user:pass@`` userinfo from a URL before logging.\n\n Operators may configure the proxy URL with embedded credentials\n (``http://user:pass@litellm:4000``); those must not reach INFO logs.\n \"\"\"\n from urllib.parse import urlsplit, urlunsplit\n\n try:\n parts = urlsplit(url)\n except ValueError:\n return url\n if not parts.scheme or \"@\" not in parts.netloc:\n return url\n host = parts.hostname or \"\"\n if parts.port is not None:\n host = f\"{host}:{parts.port}\"\n return urlunsplit((parts.scheme, f\"***@{host}\", parts.path, parts.query, parts.fragment))\n\n\ndef _redact_secrets(text: str) -> str:\n \"\"\"Scrub credential-shaped substrings from upstream error text.\n\n LiteLLM/openai surface the provider's raw HTTP response body via\n ``str(exc)``; that body can echo the ``Authorization``/``x-api-key``/\n ``Bearer`` value we sent. Redacting here keeps the actionable guidance\n (status code, model id, human hint) intact while preventing the secret\n from leaking into CLI output and logs.\n \"\"\"\n for pattern, replacement in _SECRET_PATTERNS:\n text = pattern.sub(replacement, text)\n return text\n\n\nProviderErrorClass = Literal[\"retryable\", \"fatal\"]\n\n# HTTP status → class. 408/425/429 + 5xx are worth a retry / fallback hop;\n# 4xx auth/config (400/401/403/404) won't be fixed by another attempt.\n_RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})\n_FATAL_STATUS = frozenset({400, 401, 403, 404})\n\n# Last-resort message scrape when the exception lacks ``status_code`` —\n# LiteLLM/openai surface the upstream status only in the body text.\n_STATUS_IN_MSG = re.compile(r\"(?:error\\s*code|status(?:_code)?|http)\\D{0,8}(\\d{3})\", re.I)\n\n\ndef _classify_provider_error(exc: BaseException) -> ProviderErrorClass:\n \"\"\"Tag a provider exception as ``retryable`` or ``fatal``.\n\n Inspection order (cheapest signal first):\n 1. Our own ``LLMTimeoutError`` and httpx transport errors → retryable.\n 2. ``status_code`` attribute (openai.APIStatusError shape).\n 3. ``response.status_code`` (httpx.HTTPStatusError shape).\n 4. Regex over ``str(exc)`` — LiteLLM nests upstream status in the body.\n\n Default for unrecognised shapes is ``retryable`` so we preserve today's\n behavior (LiteLLM ``num_retries`` keeps spinning). Only flip to ``fatal``\n on a positive 4xx signal.\n \"\"\"\n if isinstance(exc, LLMTimeoutError):\n return \"retryable\"\n if isinstance(exc, (httpx.TransportError, httpx.TimeoutException)):\n return \"retryable\"\n\n status = getattr(exc, \"status_code\", None)\n if status is None:\n response = getattr(exc, \"response\", None)\n status = getattr(response, \"status_code\", None)\n if isinstance(status, int):\n if status in _FATAL_STATUS:\n return \"fatal\"\n if status in _RETRYABLE_STATUS:\n return \"retryable\"\n\n match = _STATUS_IN_MSG.search(str(exc))\n if match is not None:\n code = int(match.group(1))\n if code in _FATAL_STATUS:\n return \"fatal\"\n if code in _RETRYABLE_STATUS:\n return \"retryable\"\n\n return \"retryable\"\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {_redact_secrets(str(exc))}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n fatal_prefix = (\n \"non-retryable provider error: \" if _classify_provider_error(exc) == \"fatal\" else \"\"\n )\n # Match on the raw text (status codes / keywords are not secret-shaped),\n # but interpolate the scrubbed copy so an echoed credential never reaches\n # the user-facing message — see _redact_secrets.\n safe_msg = _redact_secrets(msg)\n status = getattr(exc, \"status_code\", None)\n if status is None:\n status = getattr(getattr(exc, \"response\", None), \"status_code\", None)\n status_match = _STATUS_IN_MSG.search(msg)\n is_bad_request = (\n \"badrequest\" in err_type.lower()\n or status == 400\n or (status_match is not None and int(status_match.group(1)) == 400)\n )\n\n if is_bad_request and \"invalid model name\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' is not available through the LiteLLM \"\n f\"proxy. Verify its static route in config/litellm.yaml or dynamic environment \"\n f\"configuration (DECEPTICON_MODEL*, DECEPTICON_LITELLM_MODELS, \"\n f\"CUSTOM_OPENAI_*, or OLLAMA_MODEL). Restart the proxy, inspect its startup \"\n f\"logs, and query /v1/models for the models available to your key.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n if is_bad_request:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {safe_msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if (\n \"permissiondenied\" in err_type.lower()\n or \"forbidden\" in err_type.lower()\n or \"code: 403\" in msg_lower\n ):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' refused access (403). The \"\n f\"provider accepted your credentials but forbids this model \"\n f\"(region lock, plan tier, or org policy). Verify your account \"\n f\"can use that model, or route to another method via \"\n f\"DECEPTICON_AUTH_PRIORITY.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n # Final safety net for *unclassified* provider errors. None of the\n # branches above matched, so the caller's trailing ``raise`` would\n # re-raise ``exc`` verbatim. If its text carries a credential-shaped\n # substring (e.g. an echoed Authorization header on a 500/422/region\n # error we don't special-case), re-raising raw would leak the secret\n # into CLI output and logs. Re-wrap with the scrubbed copy. Errors with\n # no credential shape fall through untouched so their original traceback\n # is preserved (see test_unmatched_error_passes_through).\n if _CREDENTIAL_SHAPE.search(msg):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' failed with an unclassified \"\n f\"provider error. Underlying: {safe_msg}\"\n ) from exc\n\n\ndef _tiers_required(profile: ModelProfile) -> dict[Tier, list[str]]:\n if profile == ModelProfile.MAX:\n return {Tier.HIGH: list(AGENT_TIERS)}\n if profile == ModelProfile.TEST:\n return {Tier.LOW: list(AGENT_TIERS)}\n grouped: dict[Tier, list[str]] = {}\n for role, tier in AGENT_TIERS.items():\n grouped.setdefault(tier, []).append(role)\n return grouped\n\n\ndef _validate_chain_coverage(credentials: Credentials, profile: ModelProfile) -> None:\n \"\"\"Fail fast when ``credentials`` resolve to an EMPTY chain for any\n tier the factory must serve under ``profile``.\n\n ``LLMModelMapping.from_credentials_and_profile`` silently skips a role\n whose resolved chain is empty (no configured AuthMethod has a\n ``(method, tier)`` entry in ``METHOD_MODELS``). Downstream this surfaces\n as a confusing ``KeyError`` at ``get_model`` time or a primary-less\n ``ModelFallbackMiddleware([])``. Surface it once, here, with the\n offending tier(s) + the configured inventory so the operator can fix\n config immediately rather than chase a runtime failure.\n \"\"\"\n empty: list[tuple[Tier, list[str]]] = [\n (tier, roles)\n for tier, roles in _tiers_required(profile).items()\n if not resolve_chain(tier, credentials)\n ]\n if not empty:\n return\n details = \"; \".join(\n f\"{tier.value} (roles: {', '.join(sorted(roles))})\" for tier, roles in empty\n )\n inventory = \", \".join(m.value for m in credentials.methods) or \"<none>\"\n tier_list = \", \".join(tier.value for tier, _ in empty)\n raise ValueError(\n f\"LLMFactory: no model available for tier(s) [{tier_list}] under \"\n f\"profile {profile.value!r}. Empty chains: {details}. Configured \"\n f\"credentials: [{inventory}]. Add a credential whose AuthMethod \"\n f\"has an entry for the listed tier(s) in METHOD_MODELS \"\n f\"(packages/decepticon-core/decepticon_core/types/llm.py), or \"\n f\"adjust DECEPTICON_AUTH_PRIORITY / DECEPTICON_MODEL_PROFILE.\"\n )\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n raw_profile = profile if profile is not None else self._resolve_profile()\n resolved_profile = ModelProfile(raw_profile)\n _validate_chain_coverage(creds, resolved_profile)\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _compose_assignment(role: str, assignment: ModelAssignment) -> ModelAssignment:\n \"\"\"Apply a composed per-role model override to ``assignment``.\n\n Precedence (env > ``PluginBundle.models`` > tier default) is owned\n by ``decepticon.agents.build.resolve_role_model``. The displaced\n tier primary is kept as the head of the fallback chain so a bad\n override still degrades to the vetted default. Applied per get, so\n it also re-tiers custom plugin roles that resolve via a fallback\n role rather than living in ``AGENT_TIERS``. Imported lazily —\n build → factory is a normal edge; the reverse must stay absent.\n \"\"\"\n from decepticon.agents.build import resolve_role_model\n\n override = resolve_role_model(role)\n if not override or override == assignment.primary:\n return assignment\n return ModelAssignment(\n primary=override,\n fallbacks=[assignment.primary, *(m for m in assignment.fallbacks if m != override)],\n temperature=assignment.temperature,\n )\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. a downstream\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as a downstream ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._compose_assignment(\n role, self._router.get_assignment(role, default_role=default_role)\n )\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n _redact_url_credentials(self._proxy.url),\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._compose_assignment(\n role, self._router.get_assignment(role, default_role=default_role)\n )\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n # Per-model cap so large single-call deliverables (finding reports,\n # recon target models) don't truncate at the proxy's 4096 default.\n \"max_tokens\": _resolve_max_tokens(model),\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "965ca619b90f40d539d12df7651424f900e899e5": {"t": 1785755384.456233, "n": 73957, "h": "92038851aafb6088", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nimport re\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any, Literal\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AGENT_TIERS,\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelAssignment,\n ModelProfile,\n ProxyConfig,\n Tier,\n resolve_chain,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\n# Output-token cap for every model created through this factory.\n# We must set one explicitly: the LiteLLM proxy applies the Anthropic default\n# of 4096 when the client sends no ``max_tokens``, and a single large\n# ``write_file`` tool call (e.g. a full finding report or a recon target model)\n# then truncates mid-argument — the tool-call JSON never closes, so the\n# ``content`` arg is dropped and the write fails. The cap is per-model: we hand\n# each model its OWN max-output ceiling so a big deliverable completes in one\n# call. It is a ceiling, not a forced value — short replies cost nothing extra.\n# Values above 64k require the streaming API path (the SDK already streams).\n# Authoritative caps (see ``_model_max_output_tokens``): Claude Opus 4.x and\n# Sonnet (4.6/5) = 128000; Claude Haiku 4.5 = 64000. Unknown models fall back\n# to the safe 64k default. Override with ``DECEPTICON_LLM_MAX_TOKENS`` (an\n# explicit value wins over the per-model resolution).\nDEFAULT_LLM_MAX_TOKENS = 64000\nLLM_MAX_TOKENS_ENV = \"DECEPTICON_LLM_MAX_TOKENS\"\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n# `.env.example` documents the pydantic-settings nested form\n# (``DECEPTICON_LLM__TIMEOUT``) because that's what the rest of the\n# settings tree uses (``LLMConfig.timeout`` → httpx transport). The\n# whole-coroutine guard below is a separate piece of plumbing — but\n# from the user's point of view there is only one \"LLM timeout\" knob,\n# so we honor the documented name too. Precedence:\n# ``DECEPTICON_LLM_TIMEOUT_SECONDS`` (explicit, whole-coroutine) >\n# ``DECEPTICON_LLM__TIMEOUT`` (the published env knob) > default.\nLLM_TIMEOUT_ENV_ALIAS = \"DECEPTICON_LLM__TIMEOUT\"\n\n\ndef _model_max_output_tokens(model: str) -> int:\n \"\"\"Return ``model``'s own max-output-token ceiling.\n\n Match on the model slug suffix (last path segment) so every namespace we\n route through resolves the same — ``anthropic/claude-opus-4-8``,\n ``auth/claude-opus-4-8``, ``openrouter/anthropic/claude-sonnet-4-6``.\n Opus 4.x and Sonnet (4.6/5) support 128000 output tokens; Haiku 4.5\n supports 64000. Unknown / non-Claude models fall back to the safe 64k\n default rather than an over-large value the upstream might reject.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n if \"opus\" in slug or \"sonnet\" in slug:\n return 128000\n if \"haiku\" in slug:\n return 64000\n return DEFAULT_LLM_MAX_TOKENS\n\n\ndef _resolve_max_tokens(model: str) -> int:\n \"\"\"Output-token cap for a factory-created model.\n\n ``DECEPTICON_LLM_MAX_TOKENS`` (explicit override) wins when set to a\n positive int; otherwise the model's own max-output ceiling is used so a\n large single-call deliverable (finding report, recon model) never truncates\n at the proxy's 4096 default.\n \"\"\"\n raw = os.environ.get(LLM_MAX_TOKENS_ENV)\n if raw:\n try:\n value = int(raw)\n except ValueError:\n value = 0\n if value > 0:\n return value\n return _model_max_output_tokens(model)\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env >\n ``DECEPTICON_LLM__TIMEOUT`` env (the pydantic-settings nested form\n advertised in ``.env.example``) > default ``600``. The alias is what\n users actually set when following the published docs — without it,\n changing the documented knob silently has no effect on the whole-\n coroutine guard and long generations still trip the 600 s default\n (see issue #540).\n\n Rejects non-positive or non-numeric env values with ``ValueError``\n so misconfiguration fails loudly rather than silently disabling the\n guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n source = LLM_TIMEOUT_ENV\n if not raw:\n raw = os.getenv(LLM_TIMEOUT_ENV_ALIAS, \"\").strip()\n source = LLM_TIMEOUT_ENV_ALIAS\n if raw:\n try:\n value = float(raw)\n except ValueError as exc:\n raise ValueError(f\"{source} must be a number (got {raw!r})\") from exc\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{source} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Placed\n # after the first-party API providers and before the local endpoints:\n # a hosted gateway is a reasonable fallback but shouldn't preempt a\n # direct vendor key the user also configured.\n AuthMethod.OPENCODE_API,\n AuthMethod.VERCEL_GATEWAY_API,\n AuthMethod.ZENMUX_API,\n AuthMethod.NANOGPT_API,\n AuthMethod.VENICE_API,\n AuthMethod.SYNTHETIC_API,\n AuthMethod.HUGGINGFACE_API,\n AuthMethod.QIANFAN_API,\n AuthMethod.CLOUDFLARE_GATEWAY_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Each is\n # detected by the presence of a non-placeholder bearer key. The base\n # URL is fixed per gateway (see config/litellm.yaml) except Cloudflare,\n # whose per-account CLOUDFLARE_AI_GATEWAY_API_BASE must also be set —\n # documented in .env.example; a key without the base 404s at call time.\n AuthMethod.OPENCODE_API: \"OPENCODE_API_KEY\",\n AuthMethod.VERCEL_GATEWAY_API: \"VERCEL_AI_GATEWAY_API_KEY\",\n AuthMethod.HUGGINGFACE_API: \"HF_TOKEN\",\n AuthMethod.VENICE_API: \"VENICE_API_KEY\",\n AuthMethod.NANOGPT_API: \"NANOGPT_API_KEY\",\n AuthMethod.SYNTHETIC_API: \"SYNTHETIC_API_KEY\",\n AuthMethod.ZENMUX_API: \"ZENMUX_API_KEY\",\n AuthMethod.QIANFAN_API: \"QIANFAN_API_KEY\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"CLOUDFLARE_AI_GATEWAY_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n# Env vars each LiteLLM OAuth handler accepts as a credential *in place of*\n# its on-disk token file (see config/*_handler.py ``_load_tokens`` /\n# ``_env_override_tokens`` / ``_resolve_source_token``). A non-empty value in\n# any of these means the handler can authenticate without a file, so the\n# method must still count as configured. Codex (OPENAI_OAUTH) exposes no\n# env-token override — only the file-path env vars already covered by\n# ``_OAUTH_CREDENTIAL_PATHS`` — so it is intentionally absent here.\n_OAUTH_ENV_CREDENTIALS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\"ANTHROPIC_OAUTH_TOKEN\",),\n AuthMethod.GOOGLE_OAUTH: (\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"),\n AuthMethod.COPILOT_OAUTH: (\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"),\n AuthMethod.GROK_OAUTH: (\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"),\n AuthMethod.PERPLEXITY_OAUTH: (\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Any of ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint),\n ``OLLAMA_CLOUD_MODEL``, or a cloud API key\n (``OLLAMA_CLOUD_API_KEY`` / ``OLLAMA_API_KEY``) is enough to opt in.\n The key is included so a user who pasted only their key into\n `decepticon onboard` (relying on the default base + model) is still\n detected as a cloud user instead of silently falling through.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_API_KEY\", \"\").strip()\n or os.getenv(\"OLLAMA_API_KEY\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _oauth_env_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if any env var the handler accepts as a credential is set.\n\n Env-only OAuth setups (e.g. ``GEMINI_ACCESS_TOKEN`` with no token file)\n are valid: the LiteLLM handler authenticates straight from the env. Such\n a method must not be dropped from the fallback chain just because no file\n exists on disk.\n \"\"\"\n return any(os.getenv(env_var, \"\").strip() for env_var in _OAUTH_ENV_CREDENTIALS.get(method, ()))\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need the boolean intent AND a usable credential. The\n # credential may live in a token file OR in the handler's env-var\n # override — env-only setups are valid and must not be dropped.\n if not _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")):\n return False\n return _oauth_credentials_present(method) or _oauth_env_credentials_present(method)\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.OPENCODE_API: \"OpenCode Zen — API key\",\n AuthMethod.VERCEL_GATEWAY_API: \"Vercel AI Gateway — API key\",\n AuthMethod.HUGGINGFACE_API: \"Hugging Face Router — token\",\n AuthMethod.VENICE_API: \"Venice AI — API key\",\n AuthMethod.NANOGPT_API: \"NanoGPT — API key\",\n AuthMethod.SYNTHETIC_API: \"Synthetic — API key\",\n AuthMethod.ZENMUX_API: \"ZenMux — API key\",\n AuthMethod.QIANFAN_API: \"Baidu Qianfan (ERNIE) — API key\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"Cloudflare AI Gateway — API key + base URL\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n if _oauth_env_credentials_present(method):\n return f\"{flag}=true and credential present (env)\"\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n env_vars = \", \".join(_OAUTH_ENV_CREDENTIALS.get(method, ()))\n sources = paths or \"token\"\n if env_vars:\n sources = f\"{sources} or {env_vars}\"\n return f\"{flag}=true but no credential ({sources})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n result = super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n _log_served_model(self.model_name, result)\n return result\n\n async def ainvoke(self, *args, **kwargs):\n # Resolve the timeout *before* creating the request coroutine. A\n # misconfigured ``DECEPTICON_LLM_TIMEOUT_SECONDS`` raises ValueError;\n # if that were evaluated as the second argument to\n # ``call_with_timeout`` the ``super().ainvoke(...)`` coroutine would\n # already exist and leak un-awaited (\"coroutine was never awaited\").\n timeout = _resolve_llm_timeout_seconds()\n try:\n result = await call_with_timeout(super().ainvoke(*args, **kwargs), timeout)\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n _log_served_model(self.model_name, result)\n return result\n\n\ndef _log_served_model(requested: str, result: object) -> None:\n \"\"\"Best-effort attribution log: which provider/model the LiteLLM proxy\n actually routed to. When fallback fires (primary -> fallback) the\n requested model id and the served model id diverge — surfacing the\n delta lets operators trace cost/perf/debug back to the real upstream.\n\n Never raise from this path: attribution is observability, not control\n flow. Any extraction failure is swallowed silently.\n \"\"\"\n try:\n metadata = getattr(result, \"response_metadata\", None) or {}\n served = (\n metadata.get(\"model_name\")\n or metadata.get(\"model\")\n or getattr(result, \"model_name\", None)\n )\n if served and served != requested:\n log.info(\"llm.attribution requested=%s served=%s\", requested, served)\n elif served:\n log.debug(\"llm.attribution requested=%s served=%s\", requested, served)\n except Exception:\n pass\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n # Resolve the timeout before creating the request coroutine so a\n # misconfigured timeout env raises ValueError without leaving the\n # ``super()._agenerate(...)`` coroutine un-awaited.\n timeout = _resolve_llm_timeout_seconds()\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n timeout,\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\n# Patterns matching secret-shaped substrings that providers may echo back\n# inside HTTP error bodies. ``str(exc)`` for LiteLLM/openai errors is built\n# from that body, so it can reflect the Authorization/x-api-key/Bearer\n# credential we sent. Each pattern below scrubs one shape before the text is\n# interpolated into a user-facing RuntimeError that lands in CLI output/logs.\n_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (\n # \"Bearer <token>\" auth scheme (RFC 6750), case-insensitive.\n (re.compile(r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"), \"Bearer [REDACTED]\"),\n # Provider key prefixes: Anthropic sk-ant-* and OpenAI-style sk-* (any\n # remaining long token after the prefix).\n (re.compile(r\"\\bsk-(?:ant-)?[\\w.\\-]{8,}\"), \"[REDACTED]\"),\n # Header/field values for authorization / api_key / x-api-key, whether\n # JSON (\"authorization\": \"...\") or kwarg (api_key=...). Keeps the key\n # name so the guidance stays readable; only the value is scrubbed.\n (\n re.compile(\n r\"(?i)(['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*)\"\n r\"(['\\\"]?)[^'\\\"\\s,}]+\\2\"\n ),\n r\"\\1\\2[REDACTED]\\2\",\n ),\n # Generic long opaque tokens (>=24 chars of key-ish alphabet) that survive\n # the targeted passes above — e.g. provider keys without an sk- prefix.\n (re.compile(r\"\\b[A-Za-z0-9_\\-]{24,}\\b\"), \"[REDACTED]\"),\n)\n\n# High-confidence credential shapes used to decide whether an *unclassified*\n# provider error would leak a secret if its text were re-raised verbatim.\n# Deliberately excludes the broad ``>=24-char`` catch-all from\n# ``_SECRET_PATTERNS`` (that one also matches long model ids / hashes and\n# would over-trigger), keeping only the unambiguous ``Bearer``/``sk-``/\n# ``authorization:`` shapes.\n_CREDENTIAL_SHAPE = re.compile(\n r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"\n r\"|\\bsk-(?:ant-)?[\\w.\\-]{8,}\"\n r\"|['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*['\\\"]?[^'\\\"\\s,}]+\"\n)\n\n\ndef _redact_url_credentials(url: str) -> str:\n \"\"\"Strip ``user:pass@`` userinfo from a URL before logging.\n\n Operators may configure the proxy URL with embedded credentials\n (``http://user:pass@litellm:4000``); those must not reach INFO logs.\n \"\"\"\n from urllib.parse import urlsplit, urlunsplit\n\n try:\n parts = urlsplit(url)\n except ValueError:\n return url\n if not parts.scheme or \"@\" not in parts.netloc:\n return url\n host = parts.hostname or \"\"\n if parts.port is not None:\n host = f\"{host}:{parts.port}\"\n return urlunsplit((parts.scheme, f\"***@{host}\", parts.path, parts.query, parts.fragment))\n\n\ndef _redact_secrets(text: str) -> str:\n \"\"\"Scrub credential-shaped substrings from upstream error text.\n\n LiteLLM/openai surface the provider's raw HTTP response body via\n ``str(exc)``; that body can echo the ``Authorization``/``x-api-key``/\n ``Bearer`` value we sent. Redacting here keeps the actionable guidance\n (status code, model id, human hint) intact while preventing the secret\n from leaking into CLI output and logs.\n \"\"\"\n for pattern, replacement in _SECRET_PATTERNS:\n text = pattern.sub(replacement, text)\n return text\n\n\nProviderErrorClass = Literal[\"retryable\", \"fatal\"]\n\n# HTTP status → class. 408/425/429 + 5xx are worth a retry / fallback hop;\n# 4xx auth/config (400/401/403/404) won't be fixed by another attempt.\n_RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})\n_FATAL_STATUS = frozenset({400, 401, 403, 404})\n\n# Last-resort message scrape when the exception lacks ``status_code`` —\n# LiteLLM/openai surface the upstream status only in the body text.\n_STATUS_IN_MSG = re.compile(r\"(?:error\\s*code|status(?:_code)?|http)\\D{0,8}(\\d{3})\", re.I)\n\n\ndef _classify_provider_error(exc: BaseException) -> ProviderErrorClass:\n \"\"\"Tag a provider exception as ``retryable`` or ``fatal``.\n\n Inspection order (cheapest signal first):\n 1. Our own ``LLMTimeoutError`` and httpx transport errors → retryable.\n 2. ``status_code`` attribute (openai.APIStatusError shape).\n 3. ``response.status_code`` (httpx.HTTPStatusError shape).\n 4. Regex over ``str(exc)`` — LiteLLM nests upstream status in the body.\n\n Default for unrecognised shapes is ``retryable`` so we preserve today's\n behavior (LiteLLM ``num_retries`` keeps spinning). Only flip to ``fatal``\n on a positive 4xx signal.\n \"\"\"\n if isinstance(exc, LLMTimeoutError):\n return \"retryable\"\n if isinstance(exc, (httpx.TransportError, httpx.TimeoutException)):\n return \"retryable\"\n\n status = getattr(exc, \"status_code\", None)\n if status is None:\n response = getattr(exc, \"response\", None)\n status = getattr(response, \"status_code\", None)\n if isinstance(status, int):\n if status in _FATAL_STATUS:\n return \"fatal\"\n if status in _RETRYABLE_STATUS:\n return \"retryable\"\n\n match = _STATUS_IN_MSG.search(str(exc))\n if match is not None:\n code = int(match.group(1))\n if code in _FATAL_STATUS:\n return \"fatal\"\n if code in _RETRYABLE_STATUS:\n return \"retryable\"\n\n return \"retryable\"\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {_redact_secrets(str(exc))}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n fatal_prefix = (\n \"non-retryable provider error: \" if _classify_provider_error(exc) == \"fatal\" else \"\"\n )\n # Match on the raw text (status codes / keywords are not secret-shaped),\n # but interpolate the scrubbed copy so an echoed credential never reaches\n # the user-facing message — see _redact_secrets.\n safe_msg = _redact_secrets(msg)\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {safe_msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if (\n \"permissiondenied\" in err_type.lower()\n or \"forbidden\" in err_type.lower()\n or \"code: 403\" in msg_lower\n ):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' refused access (403). The \"\n f\"provider accepted your credentials but forbids this model \"\n f\"(region lock, plan tier, or org policy). Verify your account \"\n f\"can use that model, or route to another method via \"\n f\"DECEPTICON_AUTH_PRIORITY.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n # Final safety net for *unclassified* provider errors. None of the\n # branches above matched, so the caller's trailing ``raise`` would\n # re-raise ``exc`` verbatim. If its text carries a credential-shaped\n # substring (e.g. an echoed Authorization header on a 500/422/region\n # error we don't special-case), re-raising raw would leak the secret\n # into CLI output and logs. Re-wrap with the scrubbed copy. Errors with\n # no credential shape fall through untouched so their original traceback\n # is preserved (see test_unmatched_error_passes_through).\n if _CREDENTIAL_SHAPE.search(msg):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' failed with an unclassified \"\n f\"provider error. Underlying: {safe_msg}\"\n ) from exc\n\n\ndef _tiers_required(profile: ModelProfile) -> dict[Tier, list[str]]:\n if profile == ModelProfile.MAX:\n return {Tier.HIGH: list(AGENT_TIERS)}\n if profile == ModelProfile.TEST:\n return {Tier.LOW: list(AGENT_TIERS)}\n grouped: dict[Tier, list[str]] = {}\n for role, tier in AGENT_TIERS.items():\n grouped.setdefault(tier, []).append(role)\n return grouped\n\n\ndef _validate_chain_coverage(credentials: Credentials, profile: ModelProfile) -> None:\n \"\"\"Fail fast when ``credentials`` resolve to an EMPTY chain for any\n tier the factory must serve under ``profile``.\n\n ``LLMModelMapping.from_credentials_and_profile`` silently skips a role\n whose resolved chain is empty (no configured AuthMethod has a\n ``(method, tier)`` entry in ``METHOD_MODELS``). Downstream this surfaces\n as a confusing ``KeyError`` at ``get_model`` time or a primary-less\n ``ModelFallbackMiddleware([])``. Surface it once, here, with the\n offending tier(s) + the configured inventory so the operator can fix\n config immediately rather than chase a runtime failure.\n \"\"\"\n empty: list[tuple[Tier, list[str]]] = [\n (tier, roles)\n for tier, roles in _tiers_required(profile).items()\n if not resolve_chain(tier, credentials)\n ]\n if not empty:\n return\n details = \"; \".join(\n f\"{tier.value} (roles: {', '.join(sorted(roles))})\" for tier, roles in empty\n )\n inventory = \", \".join(m.value for m in credentials.methods) or \"<none>\"\n tier_list = \", \".join(tier.value for tier, _ in empty)\n raise ValueError(\n f\"LLMFactory: no model available for tier(s) [{tier_list}] under \"\n f\"profile {profile.value!r}. Empty chains: {details}. Configured \"\n f\"credentials: [{inventory}]. Add a credential whose AuthMethod \"\n f\"has an entry for the listed tier(s) in METHOD_MODELS \"\n f\"(packages/decepticon-core/decepticon_core/types/llm.py), or \"\n f\"adjust DECEPTICON_AUTH_PRIORITY / DECEPTICON_MODEL_PROFILE.\"\n )\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n raw_profile = profile if profile is not None else self._resolve_profile()\n resolved_profile = ModelProfile(raw_profile)\n _validate_chain_coverage(creds, resolved_profile)\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _compose_assignment(role: str, assignment: ModelAssignment) -> ModelAssignment:\n \"\"\"Apply a composed per-role model override to ``assignment``.\n\n Precedence (env > ``PluginBundle.models`` > tier default) is owned\n by ``decepticon.agents.build.resolve_role_model``. The displaced\n tier primary is kept as the head of the fallback chain so a bad\n override still degrades to the vetted default. Applied per get, so\n it also re-tiers custom plugin roles that resolve via a fallback\n role rather than living in ``AGENT_TIERS``. Imported lazily —\n build → factory is a normal edge; the reverse must stay absent.\n \"\"\"\n from decepticon.agents.build import resolve_role_model\n\n override = resolve_role_model(role)\n if not override or override == assignment.primary:\n return assignment\n return ModelAssignment(\n primary=override,\n fallbacks=[assignment.primary, *(m for m in assignment.fallbacks if m != override)],\n temperature=assignment.temperature,\n )\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. a downstream\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as a downstream ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._compose_assignment(\n role, self._router.get_assignment(role, default_role=default_role)\n )\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n _redact_url_credentials(self._proxy.url),\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._compose_assignment(\n role, self._router.get_assignment(role, default_role=default_role)\n )\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n # Per-model cap so large single-call deliverables (finding reports,\n # recon target models) don't truncate at the proxy's 4096 default.\n \"max_tokens\": _resolve_max_tokens(model),\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "2bc249a70de9ab51c0395d0f0aff7aa165f492f9": {"t": 1785755386.0825248, "n": 72713, "h": "00d46fc8f2445a56", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nimport re\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any, Literal\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AGENT_TIERS,\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n Tier,\n resolve_chain,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\n# Output-token cap for every model created through this factory.\n# We must set one explicitly: the LiteLLM proxy applies the Anthropic default\n# of 4096 when the client sends no ``max_tokens``, and a single large\n# ``write_file`` tool call (e.g. a full finding report or a recon target model)\n# then truncates mid-argument — the tool-call JSON never closes, so the\n# ``content`` arg is dropped and the write fails. The cap is per-model: we hand\n# each model its OWN max-output ceiling so a big deliverable completes in one\n# call. It is a ceiling, not a forced value — short replies cost nothing extra.\n# Values above 64k require the streaming API path (the SDK already streams).\n# Authoritative caps (see ``_model_max_output_tokens``): Claude Opus 4.x and\n# Sonnet (4.6/5) = 128000; Claude Haiku 4.5 = 64000. Unknown models fall back\n# to the safe 64k default. Override with ``DECEPTICON_LLM_MAX_TOKENS`` (an\n# explicit value wins over the per-model resolution).\nDEFAULT_LLM_MAX_TOKENS = 64000\nLLM_MAX_TOKENS_ENV = \"DECEPTICON_LLM_MAX_TOKENS\"\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n# `.env.example` documents the pydantic-settings nested form\n# (``DECEPTICON_LLM__TIMEOUT``) because that's what the rest of the\n# settings tree uses (``LLMConfig.timeout`` → httpx transport). The\n# whole-coroutine guard below is a separate piece of plumbing — but\n# from the user's point of view there is only one \"LLM timeout\" knob,\n# so we honor the documented name too. Precedence:\n# ``DECEPTICON_LLM_TIMEOUT_SECONDS`` (explicit, whole-coroutine) >\n# ``DECEPTICON_LLM__TIMEOUT`` (the published env knob) > default.\nLLM_TIMEOUT_ENV_ALIAS = \"DECEPTICON_LLM__TIMEOUT\"\n\n\ndef _model_max_output_tokens(model: str) -> int:\n \"\"\"Return ``model``'s own max-output-token ceiling.\n\n Match on the model slug suffix (last path segment) so every namespace we\n route through resolves the same — ``anthropic/claude-opus-4-8``,\n ``auth/claude-opus-4-8``, ``openrouter/anthropic/claude-sonnet-4-6``.\n Opus 4.x and Sonnet (4.6/5) support 128000 output tokens; Haiku 4.5\n supports 64000. Unknown / non-Claude models fall back to the safe 64k\n default rather than an over-large value the upstream might reject.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n if \"opus\" in slug or \"sonnet\" in slug:\n return 128000\n if \"haiku\" in slug:\n return 64000\n return DEFAULT_LLM_MAX_TOKENS\n\n\ndef _resolve_max_tokens(model: str) -> int:\n \"\"\"Output-token cap for a factory-created model.\n\n ``DECEPTICON_LLM_MAX_TOKENS`` (explicit override) wins when set to a\n positive int; otherwise the model's own max-output ceiling is used so a\n large single-call deliverable (finding report, recon model) never truncates\n at the proxy's 4096 default.\n \"\"\"\n raw = os.environ.get(LLM_MAX_TOKENS_ENV)\n if raw:\n try:\n value = int(raw)\n except ValueError:\n value = 0\n if value > 0:\n return value\n return _model_max_output_tokens(model)\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env >\n ``DECEPTICON_LLM__TIMEOUT`` env (the pydantic-settings nested form\n advertised in ``.env.example``) > default ``600``. The alias is what\n users actually set when following the published docs — without it,\n changing the documented knob silently has no effect on the whole-\n coroutine guard and long generations still trip the 600 s default\n (see issue #540).\n\n Rejects non-positive or non-numeric env values with ``ValueError``\n so misconfiguration fails loudly rather than silently disabling the\n guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n source = LLM_TIMEOUT_ENV\n if not raw:\n raw = os.getenv(LLM_TIMEOUT_ENV_ALIAS, \"\").strip()\n source = LLM_TIMEOUT_ENV_ALIAS\n if raw:\n try:\n value = float(raw)\n except ValueError as exc:\n raise ValueError(f\"{source} must be a number (got {raw!r})\") from exc\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{source} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Placed\n # after the first-party API providers and before the local endpoints:\n # a hosted gateway is a reasonable fallback but shouldn't preempt a\n # direct vendor key the user also configured.\n AuthMethod.OPENCODE_API,\n AuthMethod.VERCEL_GATEWAY_API,\n AuthMethod.ZENMUX_API,\n AuthMethod.NANOGPT_API,\n AuthMethod.VENICE_API,\n AuthMethod.SYNTHETIC_API,\n AuthMethod.HUGGINGFACE_API,\n AuthMethod.QIANFAN_API,\n AuthMethod.CLOUDFLARE_GATEWAY_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Each is\n # detected by the presence of a non-placeholder bearer key. The base\n # URL is fixed per gateway (see config/litellm.yaml) except Cloudflare,\n # whose per-account CLOUDFLARE_AI_GATEWAY_API_BASE must also be set —\n # documented in .env.example; a key without the base 404s at call time.\n AuthMethod.OPENCODE_API: \"OPENCODE_API_KEY\",\n AuthMethod.VERCEL_GATEWAY_API: \"VERCEL_AI_GATEWAY_API_KEY\",\n AuthMethod.HUGGINGFACE_API: \"HF_TOKEN\",\n AuthMethod.VENICE_API: \"VENICE_API_KEY\",\n AuthMethod.NANOGPT_API: \"NANOGPT_API_KEY\",\n AuthMethod.SYNTHETIC_API: \"SYNTHETIC_API_KEY\",\n AuthMethod.ZENMUX_API: \"ZENMUX_API_KEY\",\n AuthMethod.QIANFAN_API: \"QIANFAN_API_KEY\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"CLOUDFLARE_AI_GATEWAY_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n# Env vars each LiteLLM OAuth handler accepts as a credential *in place of*\n# its on-disk token file (see config/*_handler.py ``_load_tokens`` /\n# ``_env_override_tokens`` / ``_resolve_source_token``). A non-empty value in\n# any of these means the handler can authenticate without a file, so the\n# method must still count as configured. Codex (OPENAI_OAUTH) exposes no\n# env-token override — only the file-path env vars already covered by\n# ``_OAUTH_CREDENTIAL_PATHS`` — so it is intentionally absent here.\n_OAUTH_ENV_CREDENTIALS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\"ANTHROPIC_OAUTH_TOKEN\",),\n AuthMethod.GOOGLE_OAUTH: (\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"),\n AuthMethod.COPILOT_OAUTH: (\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"),\n AuthMethod.GROK_OAUTH: (\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"),\n AuthMethod.PERPLEXITY_OAUTH: (\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Any of ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint),\n ``OLLAMA_CLOUD_MODEL``, or a cloud API key\n (``OLLAMA_CLOUD_API_KEY`` / ``OLLAMA_API_KEY``) is enough to opt in.\n The key is included so a user who pasted only their key into\n `decepticon onboard` (relying on the default base + model) is still\n detected as a cloud user instead of silently falling through.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_API_KEY\", \"\").strip()\n or os.getenv(\"OLLAMA_API_KEY\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _oauth_env_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if any env var the handler accepts as a credential is set.\n\n Env-only OAuth setups (e.g. ``GEMINI_ACCESS_TOKEN`` with no token file)\n are valid: the LiteLLM handler authenticates straight from the env. Such\n a method must not be dropped from the fallback chain just because no file\n exists on disk.\n \"\"\"\n return any(os.getenv(env_var, \"\").strip() for env_var in _OAUTH_ENV_CREDENTIALS.get(method, ()))\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need the boolean intent AND a usable credential. The\n # credential may live in a token file OR in the handler's env-var\n # override — env-only setups are valid and must not be dropped.\n if not _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")):\n return False\n return _oauth_credentials_present(method) or _oauth_env_credentials_present(method)\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.OPENCODE_API: \"OpenCode Zen — API key\",\n AuthMethod.VERCEL_GATEWAY_API: \"Vercel AI Gateway — API key\",\n AuthMethod.HUGGINGFACE_API: \"Hugging Face Router — token\",\n AuthMethod.VENICE_API: \"Venice AI — API key\",\n AuthMethod.NANOGPT_API: \"NanoGPT — API key\",\n AuthMethod.SYNTHETIC_API: \"Synthetic — API key\",\n AuthMethod.ZENMUX_API: \"ZenMux — API key\",\n AuthMethod.QIANFAN_API: \"Baidu Qianfan (ERNIE) — API key\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"Cloudflare AI Gateway — API key + base URL\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n if _oauth_env_credentials_present(method):\n return f\"{flag}=true and credential present (env)\"\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n env_vars = \", \".join(_OAUTH_ENV_CREDENTIALS.get(method, ()))\n sources = paths or \"token\"\n if env_vars:\n sources = f\"{sources} or {env_vars}\"\n return f\"{flag}=true but no credential ({sources})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n result = super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n _log_served_model(self.model_name, result)\n return result\n\n async def ainvoke(self, *args, **kwargs):\n # Resolve the timeout *before* creating the request coroutine. A\n # misconfigured ``DECEPTICON_LLM_TIMEOUT_SECONDS`` raises ValueError;\n # if that were evaluated as the second argument to\n # ``call_with_timeout`` the ``super().ainvoke(...)`` coroutine would\n # already exist and leak un-awaited (\"coroutine was never awaited\").\n timeout = _resolve_llm_timeout_seconds()\n try:\n result = await call_with_timeout(super().ainvoke(*args, **kwargs), timeout)\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n _log_served_model(self.model_name, result)\n return result\n\n\ndef _log_served_model(requested: str, result: object) -> None:\n \"\"\"Best-effort attribution log: which provider/model the LiteLLM proxy\n actually routed to. When fallback fires (primary -> fallback) the\n requested model id and the served model id diverge — surfacing the\n delta lets operators trace cost/perf/debug back to the real upstream.\n\n Never raise from this path: attribution is observability, not control\n flow. Any extraction failure is swallowed silently.\n \"\"\"\n try:\n metadata = getattr(result, \"response_metadata\", None) or {}\n served = (\n metadata.get(\"model_name\")\n or metadata.get(\"model\")\n or getattr(result, \"model_name\", None)\n )\n if served and served != requested:\n log.info(\"llm.attribution requested=%s served=%s\", requested, served)\n elif served:\n log.debug(\"llm.attribution requested=%s served=%s\", requested, served)\n except Exception:\n pass\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n # Resolve the timeout before creating the request coroutine so a\n # misconfigured timeout env raises ValueError without leaving the\n # ``super()._agenerate(...)`` coroutine un-awaited.\n timeout = _resolve_llm_timeout_seconds()\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n timeout,\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\n# Patterns matching secret-shaped substrings that providers may echo back\n# inside HTTP error bodies. ``str(exc)`` for LiteLLM/openai errors is built\n# from that body, so it can reflect the Authorization/x-api-key/Bearer\n# credential we sent. Each pattern below scrubs one shape before the text is\n# interpolated into a user-facing RuntimeError that lands in CLI output/logs.\n_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (\n # \"Bearer <token>\" auth scheme (RFC 6750), case-insensitive.\n (re.compile(r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"), \"Bearer [REDACTED]\"),\n # Provider key prefixes: Anthropic sk-ant-* and OpenAI-style sk-* (any\n # remaining long token after the prefix).\n (re.compile(r\"\\bsk-(?:ant-)?[\\w.\\-]{8,}\"), \"[REDACTED]\"),\n # Header/field values for authorization / api_key / x-api-key, whether\n # JSON (\"authorization\": \"...\") or kwarg (api_key=...). Keeps the key\n # name so the guidance stays readable; only the value is scrubbed.\n (\n re.compile(\n r\"(?i)(['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*)\"\n r\"(['\\\"]?)[^'\\\"\\s,}]+\\2\"\n ),\n r\"\\1\\2[REDACTED]\\2\",\n ),\n # Generic long opaque tokens (>=24 chars of key-ish alphabet) that survive\n # the targeted passes above — e.g. provider keys without an sk- prefix.\n (re.compile(r\"\\b[A-Za-z0-9_\\-]{24,}\\b\"), \"[REDACTED]\"),\n)\n\n# High-confidence credential shapes used to decide whether an *unclassified*\n# provider error would leak a secret if its text were re-raised verbatim.\n# Deliberately excludes the broad ``>=24-char`` catch-all from\n# ``_SECRET_PATTERNS`` (that one also matches long model ids / hashes and\n# would over-trigger), keeping only the unambiguous ``Bearer``/``sk-``/\n# ``authorization:`` shapes.\n_CREDENTIAL_SHAPE = re.compile(\n r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"\n r\"|\\bsk-(?:ant-)?[\\w.\\-]{8,}\"\n r\"|['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*['\\\"]?[^'\\\"\\s,}]+\"\n)\n\n\ndef _redact_url_credentials(url: str) -> str:\n \"\"\"Strip ``user:pass@`` userinfo from a URL before logging.\n\n Operators may configure the proxy URL with embedded credentials\n (``http://user:pass@litellm:4000``); those must not reach INFO logs.\n \"\"\"\n from urllib.parse import urlsplit, urlunsplit\n\n try:\n parts = urlsplit(url)\n except ValueError:\n return url\n if not parts.scheme or \"@\" not in parts.netloc:\n return url\n host = parts.hostname or \"\"\n if parts.port is not None:\n host = f\"{host}:{parts.port}\"\n return urlunsplit((parts.scheme, f\"***@{host}\", parts.path, parts.query, parts.fragment))\n\n\ndef _redact_secrets(text: str) -> str:\n \"\"\"Scrub credential-shaped substrings from upstream error text.\n\n LiteLLM/openai surface the provider's raw HTTP response body via\n ``str(exc)``; that body can echo the ``Authorization``/``x-api-key``/\n ``Bearer`` value we sent. Redacting here keeps the actionable guidance\n (status code, model id, human hint) intact while preventing the secret\n from leaking into CLI output and logs.\n \"\"\"\n for pattern, replacement in _SECRET_PATTERNS:\n text = pattern.sub(replacement, text)\n return text\n\n\nProviderErrorClass = Literal[\"retryable\", \"fatal\"]\n\n# HTTP status → class. 408/425/429 + 5xx are worth a retry / fallback hop;\n# 4xx auth/config (400/401/403/404) won't be fixed by another attempt.\n_RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})\n_FATAL_STATUS = frozenset({400, 401, 403, 404})\n\n# Last-resort message scrape when the exception lacks ``status_code`` —\n# LiteLLM/openai surface the upstream status only in the body text.\n_STATUS_IN_MSG = re.compile(r\"(?:error\\s*code|status(?:_code)?|http)\\D{0,8}(\\d{3})\", re.I)\n\n\ndef _classify_provider_error(exc: BaseException) -> ProviderErrorClass:\n \"\"\"Tag a provider exception as ``retryable`` or ``fatal``.\n\n Inspection order (cheapest signal first):\n 1. Our own ``LLMTimeoutError`` and httpx transport errors → retryable.\n 2. ``status_code`` attribute (openai.APIStatusError shape).\n 3. ``response.status_code`` (httpx.HTTPStatusError shape).\n 4. Regex over ``str(exc)`` — LiteLLM nests upstream status in the body.\n\n Default for unrecognised shapes is ``retryable`` so we preserve today's\n behavior (LiteLLM ``num_retries`` keeps spinning). Only flip to ``fatal``\n on a positive 4xx signal.\n \"\"\"\n if isinstance(exc, LLMTimeoutError):\n return \"retryable\"\n if isinstance(exc, (httpx.TransportError, httpx.TimeoutException)):\n return \"retryable\"\n\n status = getattr(exc, \"status_code\", None)\n if status is None:\n response = getattr(exc, \"response\", None)\n status = getattr(response, \"status_code\", None)\n if isinstance(status, int):\n if status in _FATAL_STATUS:\n return \"fatal\"\n if status in _RETRYABLE_STATUS:\n return \"retryable\"\n\n match = _STATUS_IN_MSG.search(str(exc))\n if match is not None:\n code = int(match.group(1))\n if code in _FATAL_STATUS:\n return \"fatal\"\n if code in _RETRYABLE_STATUS:\n return \"retryable\"\n\n return \"retryable\"\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {_redact_secrets(str(exc))}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n fatal_prefix = (\n \"non-retryable provider error: \" if _classify_provider_error(exc) == \"fatal\" else \"\"\n )\n # Match on the raw text (status codes / keywords are not secret-shaped),\n # but interpolate the scrubbed copy so an echoed credential never reaches\n # the user-facing message — see _redact_secrets.\n safe_msg = _redact_secrets(msg)\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {safe_msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if (\n \"permissiondenied\" in err_type.lower()\n or \"forbidden\" in err_type.lower()\n or \"code: 403\" in msg_lower\n ):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' refused access (403). The \"\n f\"provider accepted your credentials but forbids this model \"\n f\"(region lock, plan tier, or org policy). Verify your account \"\n f\"can use that model, or route to another method via \"\n f\"DECEPTICON_AUTH_PRIORITY.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n # Final safety net for *unclassified* provider errors. None of the\n # branches above matched, so the caller's trailing ``raise`` would\n # re-raise ``exc`` verbatim. If its text carries a credential-shaped\n # substring (e.g. an echoed Authorization header on a 500/422/region\n # error we don't special-case), re-raising raw would leak the secret\n # into CLI output and logs. Re-wrap with the scrubbed copy. Errors with\n # no credential shape fall through untouched so their original traceback\n # is preserved (see test_unmatched_error_passes_through).\n if _CREDENTIAL_SHAPE.search(msg):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' failed with an unclassified \"\n f\"provider error. Underlying: {safe_msg}\"\n ) from exc\n\n\ndef _tiers_required(profile: ModelProfile) -> dict[Tier, list[str]]:\n if profile == ModelProfile.MAX:\n return {Tier.HIGH: list(AGENT_TIERS)}\n if profile == ModelProfile.TEST:\n return {Tier.LOW: list(AGENT_TIERS)}\n grouped: dict[Tier, list[str]] = {}\n for role, tier in AGENT_TIERS.items():\n grouped.setdefault(tier, []).append(role)\n return grouped\n\n\ndef _validate_chain_coverage(credentials: Credentials, profile: ModelProfile) -> None:\n \"\"\"Fail fast when ``credentials`` resolve to an EMPTY chain for any\n tier the factory must serve under ``profile``.\n\n ``LLMModelMapping.from_credentials_and_profile`` silently skips a role\n whose resolved chain is empty (no configured AuthMethod has a\n ``(method, tier)`` entry in ``METHOD_MODELS``). Downstream this surfaces\n as a confusing ``KeyError`` at ``get_model`` time or a primary-less\n ``ModelFallbackMiddleware([])``. Surface it once, here, with the\n offending tier(s) + the configured inventory so the operator can fix\n config immediately rather than chase a runtime failure.\n \"\"\"\n empty: list[tuple[Tier, list[str]]] = [\n (tier, roles)\n for tier, roles in _tiers_required(profile).items()\n if not resolve_chain(tier, credentials)\n ]\n if not empty:\n return\n details = \"; \".join(\n f\"{tier.value} (roles: {', '.join(sorted(roles))})\" for tier, roles in empty\n )\n inventory = \", \".join(m.value for m in credentials.methods) or \"<none>\"\n tier_list = \", \".join(tier.value for tier, _ in empty)\n raise ValueError(\n f\"LLMFactory: no model available for tier(s) [{tier_list}] under \"\n f\"profile {profile.value!r}. Empty chains: {details}. Configured \"\n f\"credentials: [{inventory}]. Add a credential whose AuthMethod \"\n f\"has an entry for the listed tier(s) in METHOD_MODELS \"\n f\"(packages/decepticon-core/decepticon_core/types/llm.py), or \"\n f\"adjust DECEPTICON_AUTH_PRIORITY / DECEPTICON_MODEL_PROFILE.\"\n )\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n raw_profile = profile if profile is not None else self._resolve_profile()\n resolved_profile = ModelProfile(raw_profile)\n _validate_chain_coverage(creds, resolved_profile)\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. a downstream\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as a downstream ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n _redact_url_credentials(self._proxy.url),\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n # Per-model cap so large single-call deliverables (finding reports,\n # recon target models) don't truncate at the proxy's 4096 default.\n \"max_tokens\": _resolve_max_tokens(model),\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "2c08feaf57d797d826d605dc4c34acf3d8bdc1bc": {"t": 1785755390.343606, "n": 71472, "h": "079df1ab13d98964", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nimport re\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any, Literal\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AGENT_TIERS,\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n Tier,\n resolve_chain,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\n# Default output-token cap for every model created through this factory.\n# We must set one explicitly: the LiteLLM proxy applies the Anthropic default\n# of 4096 when the client sends no ``max_tokens``, and a single large\n# ``write_file`` tool call (e.g. a full finding report or a recon target model)\n# then truncates mid-argument at 4096 output tokens — the tool-call JSON never\n# closes, so the ``content`` arg is dropped and the write fails. Modern Claude\n# models (opus-4-8 / sonnet-4-6 / haiku-4-5) support far larger outputs; 16384\n# is a generous cap (it is a ceiling, not a forced value — short replies cost\n# nothing extra) that lets large deliverables complete in one call.\n# Override with ``DECEPTICON_LLM_MAX_TOKENS``.\nDEFAULT_LLM_MAX_TOKENS = 16384\nLLM_MAX_TOKENS_ENV = \"DECEPTICON_LLM_MAX_TOKENS\"\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n# `.env.example` documents the pydantic-settings nested form\n# (``DECEPTICON_LLM__TIMEOUT``) because that's what the rest of the\n# settings tree uses (``LLMConfig.timeout`` → httpx transport). The\n# whole-coroutine guard below is a separate piece of plumbing — but\n# from the user's point of view there is only one \"LLM timeout\" knob,\n# so we honor the documented name too. Precedence:\n# ``DECEPTICON_LLM_TIMEOUT_SECONDS`` (explicit, whole-coroutine) >\n# ``DECEPTICON_LLM__TIMEOUT`` (the published env knob) > default.\nLLM_TIMEOUT_ENV_ALIAS = \"DECEPTICON_LLM__TIMEOUT\"\n\n\ndef _resolve_max_tokens() -> int:\n \"\"\"Output-token cap for factory-created models (env override, safe parse).\"\"\"\n raw = os.environ.get(LLM_MAX_TOKENS_ENV)\n if raw:\n try:\n value = int(raw)\n except ValueError:\n value = 0\n if value > 0:\n return value\n return DEFAULT_LLM_MAX_TOKENS\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env >\n ``DECEPTICON_LLM__TIMEOUT`` env (the pydantic-settings nested form\n advertised in ``.env.example``) > default ``600``. The alias is what\n users actually set when following the published docs — without it,\n changing the documented knob silently has no effect on the whole-\n coroutine guard and long generations still trip the 600 s default\n (see issue #540).\n\n Rejects non-positive or non-numeric env values with ``ValueError``\n so misconfiguration fails loudly rather than silently disabling the\n guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n source = LLM_TIMEOUT_ENV\n if not raw:\n raw = os.getenv(LLM_TIMEOUT_ENV_ALIAS, \"\").strip()\n source = LLM_TIMEOUT_ENV_ALIAS\n if raw:\n try:\n value = float(raw)\n except ValueError as exc:\n raise ValueError(f\"{source} must be a number (got {raw!r})\") from exc\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{source} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Placed\n # after the first-party API providers and before the local endpoints:\n # a hosted gateway is a reasonable fallback but shouldn't preempt a\n # direct vendor key the user also configured.\n AuthMethod.OPENCODE_API,\n AuthMethod.VERCEL_GATEWAY_API,\n AuthMethod.ZENMUX_API,\n AuthMethod.NANOGPT_API,\n AuthMethod.VENICE_API,\n AuthMethod.SYNTHETIC_API,\n AuthMethod.HUGGINGFACE_API,\n AuthMethod.QIANFAN_API,\n AuthMethod.CLOUDFLARE_GATEWAY_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Each is\n # detected by the presence of a non-placeholder bearer key. The base\n # URL is fixed per gateway (see config/litellm.yaml) except Cloudflare,\n # whose per-account CLOUDFLARE_AI_GATEWAY_API_BASE must also be set —\n # documented in .env.example; a key without the base 404s at call time.\n AuthMethod.OPENCODE_API: \"OPENCODE_API_KEY\",\n AuthMethod.VERCEL_GATEWAY_API: \"VERCEL_AI_GATEWAY_API_KEY\",\n AuthMethod.HUGGINGFACE_API: \"HF_TOKEN\",\n AuthMethod.VENICE_API: \"VENICE_API_KEY\",\n AuthMethod.NANOGPT_API: \"NANOGPT_API_KEY\",\n AuthMethod.SYNTHETIC_API: \"SYNTHETIC_API_KEY\",\n AuthMethod.ZENMUX_API: \"ZENMUX_API_KEY\",\n AuthMethod.QIANFAN_API: \"QIANFAN_API_KEY\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"CLOUDFLARE_AI_GATEWAY_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n# Env vars each LiteLLM OAuth handler accepts as a credential *in place of*\n# its on-disk token file (see config/*_handler.py ``_load_tokens`` /\n# ``_env_override_tokens`` / ``_resolve_source_token``). A non-empty value in\n# any of these means the handler can authenticate without a file, so the\n# method must still count as configured. Codex (OPENAI_OAUTH) exposes no\n# env-token override — only the file-path env vars already covered by\n# ``_OAUTH_CREDENTIAL_PATHS`` — so it is intentionally absent here.\n_OAUTH_ENV_CREDENTIALS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\"ANTHROPIC_OAUTH_TOKEN\",),\n AuthMethod.GOOGLE_OAUTH: (\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"),\n AuthMethod.COPILOT_OAUTH: (\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"),\n AuthMethod.GROK_OAUTH: (\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"),\n AuthMethod.PERPLEXITY_OAUTH: (\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Any of ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint),\n ``OLLAMA_CLOUD_MODEL``, or a cloud API key\n (``OLLAMA_CLOUD_API_KEY`` / ``OLLAMA_API_KEY``) is enough to opt in.\n The key is included so a user who pasted only their key into\n `decepticon onboard` (relying on the default base + model) is still\n detected as a cloud user instead of silently falling through.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_API_KEY\", \"\").strip()\n or os.getenv(\"OLLAMA_API_KEY\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _oauth_env_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if any env var the handler accepts as a credential is set.\n\n Env-only OAuth setups (e.g. ``GEMINI_ACCESS_TOKEN`` with no token file)\n are valid: the LiteLLM handler authenticates straight from the env. Such\n a method must not be dropped from the fallback chain just because no file\n exists on disk.\n \"\"\"\n return any(os.getenv(env_var, \"\").strip() for env_var in _OAUTH_ENV_CREDENTIALS.get(method, ()))\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need the boolean intent AND a usable credential. The\n # credential may live in a token file OR in the handler's env-var\n # override — env-only setups are valid and must not be dropped.\n if not _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")):\n return False\n return _oauth_credentials_present(method) or _oauth_env_credentials_present(method)\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.OPENCODE_API: \"OpenCode Zen — API key\",\n AuthMethod.VERCEL_GATEWAY_API: \"Vercel AI Gateway — API key\",\n AuthMethod.HUGGINGFACE_API: \"Hugging Face Router — token\",\n AuthMethod.VENICE_API: \"Venice AI — API key\",\n AuthMethod.NANOGPT_API: \"NanoGPT — API key\",\n AuthMethod.SYNTHETIC_API: \"Synthetic — API key\",\n AuthMethod.ZENMUX_API: \"ZenMux — API key\",\n AuthMethod.QIANFAN_API: \"Baidu Qianfan (ERNIE) — API key\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"Cloudflare AI Gateway — API key + base URL\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n if _oauth_env_credentials_present(method):\n return f\"{flag}=true and credential present (env)\"\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n env_vars = \", \".join(_OAUTH_ENV_CREDENTIALS.get(method, ()))\n sources = paths or \"token\"\n if env_vars:\n sources = f\"{sources} or {env_vars}\"\n return f\"{flag}=true but no credential ({sources})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n result = super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n _log_served_model(self.model_name, result)\n return result\n\n async def ainvoke(self, *args, **kwargs):\n # Resolve the timeout *before* creating the request coroutine. A\n # misconfigured ``DECEPTICON_LLM_TIMEOUT_SECONDS`` raises ValueError;\n # if that were evaluated as the second argument to\n # ``call_with_timeout`` the ``super().ainvoke(...)`` coroutine would\n # already exist and leak un-awaited (\"coroutine was never awaited\").\n timeout = _resolve_llm_timeout_seconds()\n try:\n result = await call_with_timeout(super().ainvoke(*args, **kwargs), timeout)\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n _log_served_model(self.model_name, result)\n return result\n\n\ndef _log_served_model(requested: str, result: object) -> None:\n \"\"\"Best-effort attribution log: which provider/model the LiteLLM proxy\n actually routed to. When fallback fires (primary -> fallback) the\n requested model id and the served model id diverge — surfacing the\n delta lets operators trace cost/perf/debug back to the real upstream.\n\n Never raise from this path: attribution is observability, not control\n flow. Any extraction failure is swallowed silently.\n \"\"\"\n try:\n metadata = getattr(result, \"response_metadata\", None) or {}\n served = (\n metadata.get(\"model_name\")\n or metadata.get(\"model\")\n or getattr(result, \"model_name\", None)\n )\n if served and served != requested:\n log.info(\"llm.attribution requested=%s served=%s\", requested, served)\n elif served:\n log.debug(\"llm.attribution requested=%s served=%s\", requested, served)\n except Exception:\n pass\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n # Resolve the timeout before creating the request coroutine so a\n # misconfigured timeout env raises ValueError without leaving the\n # ``super()._agenerate(...)`` coroutine un-awaited.\n timeout = _resolve_llm_timeout_seconds()\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n timeout,\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\n# Patterns matching secret-shaped substrings that providers may echo back\n# inside HTTP error bodies. ``str(exc)`` for LiteLLM/openai errors is built\n# from that body, so it can reflect the Authorization/x-api-key/Bearer\n# credential we sent. Each pattern below scrubs one shape before the text is\n# interpolated into a user-facing RuntimeError that lands in CLI output/logs.\n_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (\n # \"Bearer <token>\" auth scheme (RFC 6750), case-insensitive.\n (re.compile(r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"), \"Bearer [REDACTED]\"),\n # Provider key prefixes: Anthropic sk-ant-* and OpenAI-style sk-* (any\n # remaining long token after the prefix).\n (re.compile(r\"\\bsk-(?:ant-)?[\\w.\\-]{8,}\"), \"[REDACTED]\"),\n # Header/field values for authorization / api_key / x-api-key, whether\n # JSON (\"authorization\": \"...\") or kwarg (api_key=...). Keeps the key\n # name so the guidance stays readable; only the value is scrubbed.\n (\n re.compile(\n r\"(?i)(['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*)\"\n r\"(['\\\"]?)[^'\\\"\\s,}]+\\2\"\n ),\n r\"\\1\\2[REDACTED]\\2\",\n ),\n # Generic long opaque tokens (>=24 chars of key-ish alphabet) that survive\n # the targeted passes above — e.g. provider keys without an sk- prefix.\n (re.compile(r\"\\b[A-Za-z0-9_\\-]{24,}\\b\"), \"[REDACTED]\"),\n)\n\n# High-confidence credential shapes used to decide whether an *unclassified*\n# provider error would leak a secret if its text were re-raised verbatim.\n# Deliberately excludes the broad ``>=24-char`` catch-all from\n# ``_SECRET_PATTERNS`` (that one also matches long model ids / hashes and\n# would over-trigger), keeping only the unambiguous ``Bearer``/``sk-``/\n# ``authorization:`` shapes.\n_CREDENTIAL_SHAPE = re.compile(\n r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"\n r\"|\\bsk-(?:ant-)?[\\w.\\-]{8,}\"\n r\"|['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*['\\\"]?[^'\\\"\\s,}]+\"\n)\n\n\ndef _redact_url_credentials(url: str) -> str:\n \"\"\"Strip ``user:pass@`` userinfo from a URL before logging.\n\n Operators may configure the proxy URL with embedded credentials\n (``http://user:pass@litellm:4000``); those must not reach INFO logs.\n \"\"\"\n from urllib.parse import urlsplit, urlunsplit\n\n try:\n parts = urlsplit(url)\n except ValueError:\n return url\n if not parts.scheme or \"@\" not in parts.netloc:\n return url\n host = parts.hostname or \"\"\n if parts.port is not None:\n host = f\"{host}:{parts.port}\"\n return urlunsplit((parts.scheme, f\"***@{host}\", parts.path, parts.query, parts.fragment))\n\n\ndef _redact_secrets(text: str) -> str:\n \"\"\"Scrub credential-shaped substrings from upstream error text.\n\n LiteLLM/openai surface the provider's raw HTTP response body via\n ``str(exc)``; that body can echo the ``Authorization``/``x-api-key``/\n ``Bearer`` value we sent. Redacting here keeps the actionable guidance\n (status code, model id, human hint) intact while preventing the secret\n from leaking into CLI output and logs.\n \"\"\"\n for pattern, replacement in _SECRET_PATTERNS:\n text = pattern.sub(replacement, text)\n return text\n\n\nProviderErrorClass = Literal[\"retryable\", \"fatal\"]\n\n# HTTP status → class. 408/425/429 + 5xx are worth a retry / fallback hop;\n# 4xx auth/config (400/401/403/404) won't be fixed by another attempt.\n_RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})\n_FATAL_STATUS = frozenset({400, 401, 403, 404})\n\n# Last-resort message scrape when the exception lacks ``status_code`` —\n# LiteLLM/openai surface the upstream status only in the body text.\n_STATUS_IN_MSG = re.compile(r\"(?:error\\s*code|status(?:_code)?|http)\\D{0,8}(\\d{3})\", re.I)\n\n\ndef _classify_provider_error(exc: BaseException) -> ProviderErrorClass:\n \"\"\"Tag a provider exception as ``retryable`` or ``fatal``.\n\n Inspection order (cheapest signal first):\n 1. Our own ``LLMTimeoutError`` and httpx transport errors → retryable.\n 2. ``status_code`` attribute (openai.APIStatusError shape).\n 3. ``response.status_code`` (httpx.HTTPStatusError shape).\n 4. Regex over ``str(exc)`` — LiteLLM nests upstream status in the body.\n\n Default for unrecognised shapes is ``retryable`` so we preserve today's\n behavior (LiteLLM ``num_retries`` keeps spinning). Only flip to ``fatal``\n on a positive 4xx signal.\n \"\"\"\n if isinstance(exc, LLMTimeoutError):\n return \"retryable\"\n if isinstance(exc, (httpx.TransportError, httpx.TimeoutException)):\n return \"retryable\"\n\n status = getattr(exc, \"status_code\", None)\n if status is None:\n response = getattr(exc, \"response\", None)\n status = getattr(response, \"status_code\", None)\n if isinstance(status, int):\n if status in _FATAL_STATUS:\n return \"fatal\"\n if status in _RETRYABLE_STATUS:\n return \"retryable\"\n\n match = _STATUS_IN_MSG.search(str(exc))\n if match is not None:\n code = int(match.group(1))\n if code in _FATAL_STATUS:\n return \"fatal\"\n if code in _RETRYABLE_STATUS:\n return \"retryable\"\n\n return \"retryable\"\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {_redact_secrets(str(exc))}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n fatal_prefix = (\n \"non-retryable provider error: \" if _classify_provider_error(exc) == \"fatal\" else \"\"\n )\n # Match on the raw text (status codes / keywords are not secret-shaped),\n # but interpolate the scrubbed copy so an echoed credential never reaches\n # the user-facing message — see _redact_secrets.\n safe_msg = _redact_secrets(msg)\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {safe_msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if (\n \"permissiondenied\" in err_type.lower()\n or \"forbidden\" in err_type.lower()\n or \"code: 403\" in msg_lower\n ):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' refused access (403). The \"\n f\"provider accepted your credentials but forbids this model \"\n f\"(region lock, plan tier, or org policy). Verify your account \"\n f\"can use that model, or route to another method via \"\n f\"DECEPTICON_AUTH_PRIORITY.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n # Final safety net for *unclassified* provider errors. None of the\n # branches above matched, so the caller's trailing ``raise`` would\n # re-raise ``exc`` verbatim. If its text carries a credential-shaped\n # substring (e.g. an echoed Authorization header on a 500/422/region\n # error we don't special-case), re-raising raw would leak the secret\n # into CLI output and logs. Re-wrap with the scrubbed copy. Errors with\n # no credential shape fall through untouched so their original traceback\n # is preserved (see test_unmatched_error_passes_through).\n if _CREDENTIAL_SHAPE.search(msg):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' failed with an unclassified \"\n f\"provider error. Underlying: {safe_msg}\"\n ) from exc\n\n\ndef _tiers_required(profile: ModelProfile) -> dict[Tier, list[str]]:\n if profile == ModelProfile.MAX:\n return {Tier.HIGH: list(AGENT_TIERS)}\n if profile == ModelProfile.TEST:\n return {Tier.LOW: list(AGENT_TIERS)}\n grouped: dict[Tier, list[str]] = {}\n for role, tier in AGENT_TIERS.items():\n grouped.setdefault(tier, []).append(role)\n return grouped\n\n\ndef _validate_chain_coverage(credentials: Credentials, profile: ModelProfile) -> None:\n \"\"\"Fail fast when ``credentials`` resolve to an EMPTY chain for any\n tier the factory must serve under ``profile``.\n\n ``LLMModelMapping.from_credentials_and_profile`` silently skips a role\n whose resolved chain is empty (no configured AuthMethod has a\n ``(method, tier)`` entry in ``METHOD_MODELS``). Downstream this surfaces\n as a confusing ``KeyError`` at ``get_model`` time or a primary-less\n ``ModelFallbackMiddleware([])``. Surface it once, here, with the\n offending tier(s) + the configured inventory so the operator can fix\n config immediately rather than chase a runtime failure.\n \"\"\"\n empty: list[tuple[Tier, list[str]]] = [\n (tier, roles)\n for tier, roles in _tiers_required(profile).items()\n if not resolve_chain(tier, credentials)\n ]\n if not empty:\n return\n details = \"; \".join(\n f\"{tier.value} (roles: {', '.join(sorted(roles))})\" for tier, roles in empty\n )\n inventory = \", \".join(m.value for m in credentials.methods) or \"<none>\"\n tier_list = \", \".join(tier.value for tier, _ in empty)\n raise ValueError(\n f\"LLMFactory: no model available for tier(s) [{tier_list}] under \"\n f\"profile {profile.value!r}. Empty chains: {details}. Configured \"\n f\"credentials: [{inventory}]. Add a credential whose AuthMethod \"\n f\"has an entry for the listed tier(s) in METHOD_MODELS \"\n f\"(packages/decepticon-core/decepticon_core/types/llm.py), or \"\n f\"adjust DECEPTICON_AUTH_PRIORITY / DECEPTICON_MODEL_PROFILE.\"\n )\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n raw_profile = profile if profile is not None else self._resolve_profile()\n resolved_profile = ModelProfile(raw_profile)\n _validate_chain_coverage(creds, resolved_profile)\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. a downstream\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as a downstream ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n _redact_url_credentials(self._proxy.url),\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n # Explicit cap so large single-call deliverables (finding reports,\n # recon target models) don't truncate at the proxy's 4096 default.\n \"max_tokens\": _resolve_max_tokens(),\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "93e5b328e7d48276fb2a6f1c1bc9797ca6e9a8fb": {"t": 1785755393.0519347, "n": 71460, "h": "a08afecb23651996", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nimport re\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any, Literal\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AGENT_TIERS,\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n Tier,\n resolve_chain,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\n# Default output-token cap for every model created through this factory.\n# We must set one explicitly: the LiteLLM proxy applies the Anthropic default\n# of 4096 when the client sends no ``max_tokens``, and a single large\n# ``write_file`` tool call (e.g. a full finding report or a recon target model)\n# then truncates mid-argument at 4096 output tokens — the tool-call JSON never\n# closes, so the ``content`` arg is dropped and the write fails. Modern Claude\n# models (opus-4-8 / sonnet-4-6 / haiku-4-5) support far larger outputs; 16384\n# is a generous cap (it is a ceiling, not a forced value — short replies cost\n# nothing extra) that lets large deliverables complete in one call.\n# Override with ``DECEPTICON_LLM_MAX_TOKENS``.\nDEFAULT_LLM_MAX_TOKENS = 16384\nLLM_MAX_TOKENS_ENV = \"DECEPTICON_LLM_MAX_TOKENS\"\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n# `.env.example` documents the pydantic-settings nested form\n# (``DECEPTICON_LLM__TIMEOUT``) because that's what the rest of the\n# settings tree uses (``LLMConfig.timeout`` → httpx transport). The\n# whole-coroutine guard below is a separate piece of plumbing — but\n# from the user's point of view there is only one \"LLM timeout\" knob,\n# so we honor the documented name too. Precedence:\n# ``DECEPTICON_LLM_TIMEOUT_SECONDS`` (explicit, whole-coroutine) >\n# ``DECEPTICON_LLM__TIMEOUT`` (the published env knob) > default.\nLLM_TIMEOUT_ENV_ALIAS = \"DECEPTICON_LLM__TIMEOUT\"\n\n\ndef _resolve_max_tokens() -> int:\n \"\"\"Output-token cap for factory-created models (env override, safe parse).\"\"\"\n raw = os.environ.get(LLM_MAX_TOKENS_ENV)\n if raw:\n try:\n value = int(raw)\n except ValueError:\n value = 0\n if value > 0:\n return value\n return DEFAULT_LLM_MAX_TOKENS\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env >\n ``DECEPTICON_LLM__TIMEOUT`` env (the pydantic-settings nested form\n advertised in ``.env.example``) > default ``600``. The alias is what\n users actually set when following the published docs — without it,\n changing the documented knob silently has no effect on the whole-\n coroutine guard and long generations still trip the 600 s default\n (see issue #540).\n\n Rejects non-positive or non-numeric env values with ``ValueError``\n so misconfiguration fails loudly rather than silently disabling the\n guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n source = LLM_TIMEOUT_ENV\n if not raw:\n raw = os.getenv(LLM_TIMEOUT_ENV_ALIAS, \"\").strip()\n source = LLM_TIMEOUT_ENV_ALIAS\n if raw:\n try:\n value = float(raw)\n except ValueError as exc:\n raise ValueError(f\"{source} must be a number (got {raw!r})\") from exc\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{source} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Placed\n # after the first-party API providers and before the local endpoints:\n # a hosted gateway is a reasonable fallback but shouldn't preempt a\n # direct vendor key the user also configured.\n AuthMethod.OPENCODE_API,\n AuthMethod.VERCEL_GATEWAY_API,\n AuthMethod.ZENMUX_API,\n AuthMethod.NANOGPT_API,\n AuthMethod.VENICE_API,\n AuthMethod.SYNTHETIC_API,\n AuthMethod.HUGGINGFACE_API,\n AuthMethod.QIANFAN_API,\n AuthMethod.CLOUDFLARE_GATEWAY_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Each is\n # detected by the presence of a non-placeholder bearer key. The base\n # URL is fixed per gateway (see config/litellm.yaml) except Cloudflare,\n # whose per-account CLOUDFLARE_AI_GATEWAY_API_BASE must also be set —\n # documented in .env.example; a key without the base 404s at call time.\n AuthMethod.OPENCODE_API: \"OPENCODE_API_KEY\",\n AuthMethod.VERCEL_GATEWAY_API: \"VERCEL_AI_GATEWAY_API_KEY\",\n AuthMethod.HUGGINGFACE_API: \"HF_TOKEN\",\n AuthMethod.VENICE_API: \"VENICE_API_KEY\",\n AuthMethod.NANOGPT_API: \"NANOGPT_API_KEY\",\n AuthMethod.SYNTHETIC_API: \"SYNTHETIC_API_KEY\",\n AuthMethod.ZENMUX_API: \"ZENMUX_API_KEY\",\n AuthMethod.QIANFAN_API: \"QIANFAN_API_KEY\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"CLOUDFLARE_AI_GATEWAY_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n# Env vars each LiteLLM OAuth handler accepts as a credential *in place of*\n# its on-disk token file (see config/*_handler.py ``_load_tokens`` /\n# ``_env_override_tokens`` / ``_resolve_source_token``). A non-empty value in\n# any of these means the handler can authenticate without a file, so the\n# method must still count as configured. Codex (OPENAI_OAUTH) exposes no\n# env-token override — only the file-path env vars already covered by\n# ``_OAUTH_CREDENTIAL_PATHS`` — so it is intentionally absent here.\n_OAUTH_ENV_CREDENTIALS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\"ANTHROPIC_OAUTH_TOKEN\",),\n AuthMethod.GOOGLE_OAUTH: (\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"),\n AuthMethod.COPILOT_OAUTH: (\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"),\n AuthMethod.GROK_OAUTH: (\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"),\n AuthMethod.PERPLEXITY_OAUTH: (\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Any of ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint),\n ``OLLAMA_CLOUD_MODEL``, or a cloud API key\n (``OLLAMA_CLOUD_API_KEY`` / ``OLLAMA_API_KEY``) is enough to opt in.\n The key is included so a user who pasted only their key into\n `decepticon onboard` (relying on the default base + model) is still\n detected as a cloud user instead of silently falling through.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_API_KEY\", \"\").strip()\n or os.getenv(\"OLLAMA_API_KEY\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _oauth_env_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if any env var the handler accepts as a credential is set.\n\n Env-only OAuth setups (e.g. ``GEMINI_ACCESS_TOKEN`` with no token file)\n are valid: the LiteLLM handler authenticates straight from the env. Such\n a method must not be dropped from the fallback chain just because no file\n exists on disk.\n \"\"\"\n return any(os.getenv(env_var, \"\").strip() for env_var in _OAUTH_ENV_CREDENTIALS.get(method, ()))\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need the boolean intent AND a usable credential. The\n # credential may live in a token file OR in the handler's env-var\n # override — env-only setups are valid and must not be dropped.\n if not _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")):\n return False\n return _oauth_credentials_present(method) or _oauth_env_credentials_present(method)\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.OPENCODE_API: \"OpenCode Zen — API key\",\n AuthMethod.VERCEL_GATEWAY_API: \"Vercel AI Gateway — API key\",\n AuthMethod.HUGGINGFACE_API: \"Hugging Face Router — token\",\n AuthMethod.VENICE_API: \"Venice AI — API key\",\n AuthMethod.NANOGPT_API: \"NanoGPT — API key\",\n AuthMethod.SYNTHETIC_API: \"Synthetic — API key\",\n AuthMethod.ZENMUX_API: \"ZenMux — API key\",\n AuthMethod.QIANFAN_API: \"Baidu Qianfan (ERNIE) — API key\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"Cloudflare AI Gateway — API key + base URL\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n if _oauth_env_credentials_present(method):\n return f\"{flag}=true and credential present (env)\"\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n env_vars = \", \".join(_OAUTH_ENV_CREDENTIALS.get(method, ()))\n sources = paths or \"token\"\n if env_vars:\n sources = f\"{sources} or {env_vars}\"\n return f\"{flag}=true but no credential ({sources})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n result = super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n _log_served_model(self.model_name, result)\n return result\n\n async def ainvoke(self, *args, **kwargs):\n # Resolve the timeout *before* creating the request coroutine. A\n # misconfigured ``DECEPTICON_LLM_TIMEOUT_SECONDS`` raises ValueError;\n # if that were evaluated as the second argument to\n # ``call_with_timeout`` the ``super().ainvoke(...)`` coroutine would\n # already exist and leak un-awaited (\"coroutine was never awaited\").\n timeout = _resolve_llm_timeout_seconds()\n try:\n result = await call_with_timeout(super().ainvoke(*args, **kwargs), timeout)\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n _log_served_model(self.model_name, result)\n return result\n\n\ndef _log_served_model(requested: str, result: object) -> None:\n \"\"\"Best-effort attribution log: which provider/model the LiteLLM proxy\n actually routed to. When fallback fires (primary -> fallback) the\n requested model id and the served model id diverge — surfacing the\n delta lets operators trace cost/perf/debug back to the real upstream.\n\n Never raise from this path: attribution is observability, not control\n flow. Any extraction failure is swallowed silently.\n \"\"\"\n try:\n metadata = getattr(result, \"response_metadata\", None) or {}\n served = (\n metadata.get(\"model_name\")\n or metadata.get(\"model\")\n or getattr(result, \"model_name\", None)\n )\n if served and served != requested:\n log.info(\"llm.attribution requested=%s served=%s\", requested, served)\n elif served:\n log.debug(\"llm.attribution requested=%s served=%s\", requested, served)\n except Exception:\n pass\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n # Resolve the timeout before creating the request coroutine so a\n # misconfigured timeout env raises ValueError without leaving the\n # ``super()._agenerate(...)`` coroutine un-awaited.\n timeout = _resolve_llm_timeout_seconds()\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n timeout,\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\n# Patterns matching secret-shaped substrings that providers may echo back\n# inside HTTP error bodies. ``str(exc)`` for LiteLLM/openai errors is built\n# from that body, so it can reflect the Authorization/x-api-key/Bearer\n# credential we sent. Each pattern below scrubs one shape before the text is\n# interpolated into a user-facing RuntimeError that lands in CLI output/logs.\n_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (\n # \"Bearer <token>\" auth scheme (RFC 6750), case-insensitive.\n (re.compile(r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"), \"Bearer [REDACTED]\"),\n # Provider key prefixes: Anthropic sk-ant-* and OpenAI-style sk-* (any\n # remaining long token after the prefix).\n (re.compile(r\"\\bsk-(?:ant-)?[\\w.\\-]{8,}\"), \"[REDACTED]\"),\n # Header/field values for authorization / api_key / x-api-key, whether\n # JSON (\"authorization\": \"...\") or kwarg (api_key=...). Keeps the key\n # name so the guidance stays readable; only the value is scrubbed.\n (\n re.compile(\n r\"(?i)(['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*)\"\n r\"(['\\\"]?)[^'\\\"\\s,}]+\\2\"\n ),\n r\"\\1\\2[REDACTED]\\2\",\n ),\n # Generic long opaque tokens (>=24 chars of key-ish alphabet) that survive\n # the targeted passes above — e.g. provider keys without an sk- prefix.\n (re.compile(r\"\\b[A-Za-z0-9_\\-]{24,}\\b\"), \"[REDACTED]\"),\n)\n\n# High-confidence credential shapes used to decide whether an *unclassified*\n# provider error would leak a secret if its text were re-raised verbatim.\n# Deliberately excludes the broad ``>=24-char`` catch-all from\n# ``_SECRET_PATTERNS`` (that one also matches long model ids / hashes and\n# would over-trigger), keeping only the unambiguous ``Bearer``/``sk-``/\n# ``authorization:`` shapes.\n_CREDENTIAL_SHAPE = re.compile(\n r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"\n r\"|\\bsk-(?:ant-)?[\\w.\\-]{8,}\"\n r\"|['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*['\\\"]?[^'\\\"\\s,}]+\"\n)\n\n\ndef _redact_url_credentials(url: str) -> str:\n \"\"\"Strip ``user:pass@`` userinfo from a URL before logging.\n\n Operators may configure the proxy URL with embedded credentials\n (``http://user:pass@litellm:4000``); those must not reach INFO logs.\n \"\"\"\n from urllib.parse import urlsplit, urlunsplit\n\n try:\n parts = urlsplit(url)\n except ValueError:\n return url\n if not parts.scheme or \"@\" not in parts.netloc:\n return url\n host = parts.hostname or \"\"\n if parts.port is not None:\n host = f\"{host}:{parts.port}\"\n return urlunsplit((parts.scheme, f\"***@{host}\", parts.path, parts.query, parts.fragment))\n\n\ndef _redact_secrets(text: str) -> str:\n \"\"\"Scrub credential-shaped substrings from upstream error text.\n\n LiteLLM/openai surface the provider's raw HTTP response body via\n ``str(exc)``; that body can echo the ``Authorization``/``x-api-key``/\n ``Bearer`` value we sent. Redacting here keeps the actionable guidance\n (status code, model id, human hint) intact while preventing the secret\n from leaking into CLI output and logs.\n \"\"\"\n for pattern, replacement in _SECRET_PATTERNS:\n text = pattern.sub(replacement, text)\n return text\n\n\nProviderErrorClass = Literal[\"retryable\", \"fatal\"]\n\n# HTTP status → class. 408/425/429 + 5xx are worth a retry / fallback hop;\n# 4xx auth/config (400/401/403/404) won't be fixed by another attempt.\n_RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})\n_FATAL_STATUS = frozenset({400, 401, 403, 404})\n\n# Last-resort message scrape when the exception lacks ``status_code`` —\n# LiteLLM/openai surface the upstream status only in the body text.\n_STATUS_IN_MSG = re.compile(r\"(?:error\\s*code|status(?:_code)?|http)\\D{0,8}(\\d{3})\", re.I)\n\n\ndef _classify_provider_error(exc: BaseException) -> ProviderErrorClass:\n \"\"\"Tag a provider exception as ``retryable`` or ``fatal``.\n\n Inspection order (cheapest signal first):\n 1. Our own ``LLMTimeoutError`` and httpx transport errors → retryable.\n 2. ``status_code`` attribute (openai.APIStatusError shape).\n 3. ``response.status_code`` (httpx.HTTPStatusError shape).\n 4. Regex over ``str(exc)`` — LiteLLM nests upstream status in the body.\n\n Default for unrecognised shapes is ``retryable`` so we preserve today's\n behavior (LiteLLM ``num_retries`` keeps spinning). Only flip to ``fatal``\n on a positive 4xx signal.\n \"\"\"\n if isinstance(exc, LLMTimeoutError):\n return \"retryable\"\n if isinstance(exc, (httpx.TransportError, httpx.TimeoutException)):\n return \"retryable\"\n\n status = getattr(exc, \"status_code\", None)\n if status is None:\n response = getattr(exc, \"response\", None)\n status = getattr(response, \"status_code\", None)\n if isinstance(status, int):\n if status in _FATAL_STATUS:\n return \"fatal\"\n if status in _RETRYABLE_STATUS:\n return \"retryable\"\n\n match = _STATUS_IN_MSG.search(str(exc))\n if match is not None:\n code = int(match.group(1))\n if code in _FATAL_STATUS:\n return \"fatal\"\n if code in _RETRYABLE_STATUS:\n return \"retryable\"\n\n return \"retryable\"\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {_redact_secrets(str(exc))}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n fatal_prefix = (\n \"non-retryable provider error: \" if _classify_provider_error(exc) == \"fatal\" else \"\"\n )\n # Match on the raw text (status codes / keywords are not secret-shaped),\n # but interpolate the scrubbed copy so an echoed credential never reaches\n # the user-facing message — see _redact_secrets.\n safe_msg = _redact_secrets(msg)\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {safe_msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if (\n \"permissiondenied\" in err_type.lower()\n or \"forbidden\" in err_type.lower()\n or \"code: 403\" in msg_lower\n ):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' refused access (403). The \"\n f\"provider accepted your credentials but forbids this model \"\n f\"(region lock, plan tier, or org policy). Verify your account \"\n f\"can use that model, or route to another method via \"\n f\"DECEPTICON_AUTH_PRIORITY.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n # Final safety net for *unclassified* provider errors. None of the\n # branches above matched, so the caller's trailing ``raise`` would\n # re-raise ``exc`` verbatim. If its text carries a credential-shaped\n # substring (e.g. an echoed Authorization header on a 500/422/region\n # error we don't special-case), re-raising raw would leak the secret\n # into CLI output and logs. Re-wrap with the scrubbed copy. Errors with\n # no credential shape fall through untouched so their original traceback\n # is preserved (see test_unmatched_error_passes_through).\n if _CREDENTIAL_SHAPE.search(msg):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' failed with an unclassified \"\n f\"provider error. Underlying: {safe_msg}\"\n ) from exc\n\n\ndef _tiers_required(profile: ModelProfile) -> dict[Tier, list[str]]:\n if profile == ModelProfile.MAX:\n return {Tier.HIGH: list(AGENT_TIERS)}\n if profile == ModelProfile.TEST:\n return {Tier.LOW: list(AGENT_TIERS)}\n grouped: dict[Tier, list[str]] = {}\n for role, tier in AGENT_TIERS.items():\n grouped.setdefault(tier, []).append(role)\n return grouped\n\n\ndef _validate_chain_coverage(credentials: Credentials, profile: ModelProfile) -> None:\n \"\"\"Fail fast when ``credentials`` resolve to an EMPTY chain for any\n tier the factory must serve under ``profile``.\n\n ``LLMModelMapping.from_credentials_and_profile`` silently skips a role\n whose resolved chain is empty (no configured AuthMethod has a\n ``(method, tier)`` entry in ``METHOD_MODELS``). Downstream this surfaces\n as a confusing ``KeyError`` at ``get_model`` time or a primary-less\n ``ModelFallbackMiddleware([])``. Surface it once, here, with the\n offending tier(s) + the configured inventory so the operator can fix\n config immediately rather than chase a runtime failure.\n \"\"\"\n empty: list[tuple[Tier, list[str]]] = [\n (tier, roles)\n for tier, roles in _tiers_required(profile).items()\n if not resolve_chain(tier, credentials)\n ]\n if not empty:\n return\n details = \"; \".join(\n f\"{tier.value} (roles: {', '.join(sorted(roles))})\" for tier, roles in empty\n )\n inventory = \", \".join(m.value for m in credentials.methods) or \"<none>\"\n tier_list = \", \".join(tier.value for tier, _ in empty)\n raise ValueError(\n f\"LLMFactory: no model available for tier(s) [{tier_list}] under \"\n f\"profile {profile.value!r}. Empty chains: {details}. Configured \"\n f\"credentials: [{inventory}]. Add a credential whose AuthMethod \"\n f\"has an entry for the listed tier(s) in METHOD_MODELS \"\n f\"(packages/decepticon-core/decepticon_core/types/llm.py), or \"\n f\"adjust DECEPTICON_AUTH_PRIORITY / DECEPTICON_MODEL_PROFILE.\"\n )\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n raw_profile = profile if profile is not None else self._resolve_profile()\n resolved_profile = ModelProfile(raw_profile)\n _validate_chain_coverage(creds, resolved_profile)\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n _redact_url_credentials(self._proxy.url),\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n # Explicit cap so large single-call deliverables (finding reports,\n # recon target models) don't truncate at the proxy's 4096 default.\n \"max_tokens\": _resolve_max_tokens(),\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "d3bc3f09b0942c49aa3f7c120529dd3b71455871": {"t": 1785755394.8413281, "n": 70094, "h": "6a8ef31904959525", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nimport re\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any, Literal\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AGENT_TIERS,\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n Tier,\n resolve_chain,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n# `.env.example` documents the pydantic-settings nested form\n# (``DECEPTICON_LLM__TIMEOUT``) because that's what the rest of the\n# settings tree uses (``LLMConfig.timeout`` → httpx transport). The\n# whole-coroutine guard below is a separate piece of plumbing — but\n# from the user's point of view there is only one \"LLM timeout\" knob,\n# so we honor the documented name too. Precedence:\n# ``DECEPTICON_LLM_TIMEOUT_SECONDS`` (explicit, whole-coroutine) >\n# ``DECEPTICON_LLM__TIMEOUT`` (the published env knob) > default.\nLLM_TIMEOUT_ENV_ALIAS = \"DECEPTICON_LLM__TIMEOUT\"\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env >\n ``DECEPTICON_LLM__TIMEOUT`` env (the pydantic-settings nested form\n advertised in ``.env.example``) > default ``600``. The alias is what\n users actually set when following the published docs — without it,\n changing the documented knob silently has no effect on the whole-\n coroutine guard and long generations still trip the 600 s default\n (see issue #540).\n\n Rejects non-positive or non-numeric env values with ``ValueError``\n so misconfiguration fails loudly rather than silently disabling the\n guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n source = LLM_TIMEOUT_ENV\n if not raw:\n raw = os.getenv(LLM_TIMEOUT_ENV_ALIAS, \"\").strip()\n source = LLM_TIMEOUT_ENV_ALIAS\n if raw:\n try:\n value = float(raw)\n except ValueError as exc:\n raise ValueError(f\"{source} must be a number (got {raw!r})\") from exc\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{source} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Placed\n # after the first-party API providers and before the local endpoints:\n # a hosted gateway is a reasonable fallback but shouldn't preempt a\n # direct vendor key the user also configured.\n AuthMethod.OPENCODE_API,\n AuthMethod.VERCEL_GATEWAY_API,\n AuthMethod.ZENMUX_API,\n AuthMethod.NANOGPT_API,\n AuthMethod.VENICE_API,\n AuthMethod.SYNTHETIC_API,\n AuthMethod.HUGGINGFACE_API,\n AuthMethod.QIANFAN_API,\n AuthMethod.CLOUDFLARE_GATEWAY_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Each is\n # detected by the presence of a non-placeholder bearer key. The base\n # URL is fixed per gateway (see config/litellm.yaml) except Cloudflare,\n # whose per-account CLOUDFLARE_AI_GATEWAY_API_BASE must also be set —\n # documented in .env.example; a key without the base 404s at call time.\n AuthMethod.OPENCODE_API: \"OPENCODE_API_KEY\",\n AuthMethod.VERCEL_GATEWAY_API: \"VERCEL_AI_GATEWAY_API_KEY\",\n AuthMethod.HUGGINGFACE_API: \"HF_TOKEN\",\n AuthMethod.VENICE_API: \"VENICE_API_KEY\",\n AuthMethod.NANOGPT_API: \"NANOGPT_API_KEY\",\n AuthMethod.SYNTHETIC_API: \"SYNTHETIC_API_KEY\",\n AuthMethod.ZENMUX_API: \"ZENMUX_API_KEY\",\n AuthMethod.QIANFAN_API: \"QIANFAN_API_KEY\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"CLOUDFLARE_AI_GATEWAY_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n# Env vars each LiteLLM OAuth handler accepts as a credential *in place of*\n# its on-disk token file (see config/*_handler.py ``_load_tokens`` /\n# ``_env_override_tokens`` / ``_resolve_source_token``). A non-empty value in\n# any of these means the handler can authenticate without a file, so the\n# method must still count as configured. Codex (OPENAI_OAUTH) exposes no\n# env-token override — only the file-path env vars already covered by\n# ``_OAUTH_CREDENTIAL_PATHS`` — so it is intentionally absent here.\n_OAUTH_ENV_CREDENTIALS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\"ANTHROPIC_OAUTH_TOKEN\",),\n AuthMethod.GOOGLE_OAUTH: (\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"),\n AuthMethod.COPILOT_OAUTH: (\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"),\n AuthMethod.GROK_OAUTH: (\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"),\n AuthMethod.PERPLEXITY_OAUTH: (\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Any of ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint),\n ``OLLAMA_CLOUD_MODEL``, or a cloud API key\n (``OLLAMA_CLOUD_API_KEY`` / ``OLLAMA_API_KEY``) is enough to opt in.\n The key is included so a user who pasted only their key into\n `decepticon onboard` (relying on the default base + model) is still\n detected as a cloud user instead of silently falling through.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_API_KEY\", \"\").strip()\n or os.getenv(\"OLLAMA_API_KEY\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _oauth_env_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if any env var the handler accepts as a credential is set.\n\n Env-only OAuth setups (e.g. ``GEMINI_ACCESS_TOKEN`` with no token file)\n are valid: the LiteLLM handler authenticates straight from the env. Such\n a method must not be dropped from the fallback chain just because no file\n exists on disk.\n \"\"\"\n return any(os.getenv(env_var, \"\").strip() for env_var in _OAUTH_ENV_CREDENTIALS.get(method, ()))\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need the boolean intent AND a usable credential. The\n # credential may live in a token file OR in the handler's env-var\n # override — env-only setups are valid and must not be dropped.\n if not _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")):\n return False\n return _oauth_credentials_present(method) or _oauth_env_credentials_present(method)\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.OPENCODE_API: \"OpenCode Zen — API key\",\n AuthMethod.VERCEL_GATEWAY_API: \"Vercel AI Gateway — API key\",\n AuthMethod.HUGGINGFACE_API: \"Hugging Face Router — token\",\n AuthMethod.VENICE_API: \"Venice AI — API key\",\n AuthMethod.NANOGPT_API: \"NanoGPT — API key\",\n AuthMethod.SYNTHETIC_API: \"Synthetic — API key\",\n AuthMethod.ZENMUX_API: \"ZenMux — API key\",\n AuthMethod.QIANFAN_API: \"Baidu Qianfan (ERNIE) — API key\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"Cloudflare AI Gateway — API key + base URL\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n if _oauth_env_credentials_present(method):\n return f\"{flag}=true and credential present (env)\"\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n env_vars = \", \".join(_OAUTH_ENV_CREDENTIALS.get(method, ()))\n sources = paths or \"token\"\n if env_vars:\n sources = f\"{sources} or {env_vars}\"\n return f\"{flag}=true but no credential ({sources})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n result = super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n _log_served_model(self.model_name, result)\n return result\n\n async def ainvoke(self, *args, **kwargs):\n # Resolve the timeout *before* creating the request coroutine. A\n # misconfigured ``DECEPTICON_LLM_TIMEOUT_SECONDS`` raises ValueError;\n # if that were evaluated as the second argument to\n # ``call_with_timeout`` the ``super().ainvoke(...)`` coroutine would\n # already exist and leak un-awaited (\"coroutine was never awaited\").\n timeout = _resolve_llm_timeout_seconds()\n try:\n result = await call_with_timeout(super().ainvoke(*args, **kwargs), timeout)\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n _log_served_model(self.model_name, result)\n return result\n\n\ndef _log_served_model(requested: str, result: object) -> None:\n \"\"\"Best-effort attribution log: which provider/model the LiteLLM proxy\n actually routed to. When fallback fires (primary -> fallback) the\n requested model id and the served model id diverge — surfacing the\n delta lets operators trace cost/perf/debug back to the real upstream.\n\n Never raise from this path: attribution is observability, not control\n flow. Any extraction failure is swallowed silently.\n \"\"\"\n try:\n metadata = getattr(result, \"response_metadata\", None) or {}\n served = (\n metadata.get(\"model_name\")\n or metadata.get(\"model\")\n or getattr(result, \"model_name\", None)\n )\n if served and served != requested:\n log.info(\"llm.attribution requested=%s served=%s\", requested, served)\n elif served:\n log.debug(\"llm.attribution requested=%s served=%s\", requested, served)\n except Exception:\n pass\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n # Resolve the timeout before creating the request coroutine so a\n # misconfigured timeout env raises ValueError without leaving the\n # ``super()._agenerate(...)`` coroutine un-awaited.\n timeout = _resolve_llm_timeout_seconds()\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n timeout,\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\n# Patterns matching secret-shaped substrings that providers may echo back\n# inside HTTP error bodies. ``str(exc)`` for LiteLLM/openai errors is built\n# from that body, so it can reflect the Authorization/x-api-key/Bearer\n# credential we sent. Each pattern below scrubs one shape before the text is\n# interpolated into a user-facing RuntimeError that lands in CLI output/logs.\n_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (\n # \"Bearer <token>\" auth scheme (RFC 6750), case-insensitive.\n (re.compile(r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"), \"Bearer [REDACTED]\"),\n # Provider key prefixes: Anthropic sk-ant-* and OpenAI-style sk-* (any\n # remaining long token after the prefix).\n (re.compile(r\"\\bsk-(?:ant-)?[\\w.\\-]{8,}\"), \"[REDACTED]\"),\n # Header/field values for authorization / api_key / x-api-key, whether\n # JSON (\"authorization\": \"...\") or kwarg (api_key=...). Keeps the key\n # name so the guidance stays readable; only the value is scrubbed.\n (\n re.compile(\n r\"(?i)(['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*)\"\n r\"(['\\\"]?)[^'\\\"\\s,}]+\\2\"\n ),\n r\"\\1\\2[REDACTED]\\2\",\n ),\n # Generic long opaque tokens (>=24 chars of key-ish alphabet) that survive\n # the targeted passes above — e.g. provider keys without an sk- prefix.\n (re.compile(r\"\\b[A-Za-z0-9_\\-]{24,}\\b\"), \"[REDACTED]\"),\n)\n\n# High-confidence credential shapes used to decide whether an *unclassified*\n# provider error would leak a secret if its text were re-raised verbatim.\n# Deliberately excludes the broad ``>=24-char`` catch-all from\n# ``_SECRET_PATTERNS`` (that one also matches long model ids / hashes and\n# would over-trigger), keeping only the unambiguous ``Bearer``/``sk-``/\n# ``authorization:`` shapes.\n_CREDENTIAL_SHAPE = re.compile(\n r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"\n r\"|\\bsk-(?:ant-)?[\\w.\\-]{8,}\"\n r\"|['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*['\\\"]?[^'\\\"\\s,}]+\"\n)\n\n\ndef _redact_url_credentials(url: str) -> str:\n \"\"\"Strip ``user:pass@`` userinfo from a URL before logging.\n\n Operators may configure the proxy URL with embedded credentials\n (``http://user:pass@litellm:4000``); those must not reach INFO logs.\n \"\"\"\n from urllib.parse import urlsplit, urlunsplit\n\n try:\n parts = urlsplit(url)\n except ValueError:\n return url\n if not parts.scheme or \"@\" not in parts.netloc:\n return url\n host = parts.hostname or \"\"\n if parts.port is not None:\n host = f\"{host}:{parts.port}\"\n return urlunsplit((parts.scheme, f\"***@{host}\", parts.path, parts.query, parts.fragment))\n\n\ndef _redact_secrets(text: str) -> str:\n \"\"\"Scrub credential-shaped substrings from upstream error text.\n\n LiteLLM/openai surface the provider's raw HTTP response body via\n ``str(exc)``; that body can echo the ``Authorization``/``x-api-key``/\n ``Bearer`` value we sent. Redacting here keeps the actionable guidance\n (status code, model id, human hint) intact while preventing the secret\n from leaking into CLI output and logs.\n \"\"\"\n for pattern, replacement in _SECRET_PATTERNS:\n text = pattern.sub(replacement, text)\n return text\n\n\nProviderErrorClass = Literal[\"retryable\", \"fatal\"]\n\n# HTTP status → class. 408/425/429 + 5xx are worth a retry / fallback hop;\n# 4xx auth/config (400/401/403/404) won't be fixed by another attempt.\n_RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})\n_FATAL_STATUS = frozenset({400, 401, 403, 404})\n\n# Last-resort message scrape when the exception lacks ``status_code`` —\n# LiteLLM/openai surface the upstream status only in the body text.\n_STATUS_IN_MSG = re.compile(r\"(?:error\\s*code|status(?:_code)?|http)\\D{0,8}(\\d{3})\", re.I)\n\n\ndef _classify_provider_error(exc: BaseException) -> ProviderErrorClass:\n \"\"\"Tag a provider exception as ``retryable`` or ``fatal``.\n\n Inspection order (cheapest signal first):\n 1. Our own ``LLMTimeoutError`` and httpx transport errors → retryable.\n 2. ``status_code`` attribute (openai.APIStatusError shape).\n 3. ``response.status_code`` (httpx.HTTPStatusError shape).\n 4. Regex over ``str(exc)`` — LiteLLM nests upstream status in the body.\n\n Default for unrecognised shapes is ``retryable`` so we preserve today's\n behavior (LiteLLM ``num_retries`` keeps spinning). Only flip to ``fatal``\n on a positive 4xx signal.\n \"\"\"\n if isinstance(exc, LLMTimeoutError):\n return \"retryable\"\n if isinstance(exc, (httpx.TransportError, httpx.TimeoutException)):\n return \"retryable\"\n\n status = getattr(exc, \"status_code\", None)\n if status is None:\n response = getattr(exc, \"response\", None)\n status = getattr(response, \"status_code\", None)\n if isinstance(status, int):\n if status in _FATAL_STATUS:\n return \"fatal\"\n if status in _RETRYABLE_STATUS:\n return \"retryable\"\n\n match = _STATUS_IN_MSG.search(str(exc))\n if match is not None:\n code = int(match.group(1))\n if code in _FATAL_STATUS:\n return \"fatal\"\n if code in _RETRYABLE_STATUS:\n return \"retryable\"\n\n return \"retryable\"\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {_redact_secrets(str(exc))}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n fatal_prefix = (\n \"non-retryable provider error: \" if _classify_provider_error(exc) == \"fatal\" else \"\"\n )\n # Match on the raw text (status codes / keywords are not secret-shaped),\n # but interpolate the scrubbed copy so an echoed credential never reaches\n # the user-facing message — see _redact_secrets.\n safe_msg = _redact_secrets(msg)\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {safe_msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if (\n \"permissiondenied\" in err_type.lower()\n or \"forbidden\" in err_type.lower()\n or \"code: 403\" in msg_lower\n ):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' refused access (403). The \"\n f\"provider accepted your credentials but forbids this model \"\n f\"(region lock, plan tier, or org policy). Verify your account \"\n f\"can use that model, or route to another method via \"\n f\"DECEPTICON_AUTH_PRIORITY.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n # Final safety net for *unclassified* provider errors. None of the\n # branches above matched, so the caller's trailing ``raise`` would\n # re-raise ``exc`` verbatim. If its text carries a credential-shaped\n # substring (e.g. an echoed Authorization header on a 500/422/region\n # error we don't special-case), re-raising raw would leak the secret\n # into CLI output and logs. Re-wrap with the scrubbed copy. Errors with\n # no credential shape fall through untouched so their original traceback\n # is preserved (see test_unmatched_error_passes_through).\n if _CREDENTIAL_SHAPE.search(msg):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' failed with an unclassified \"\n f\"provider error. Underlying: {safe_msg}\"\n ) from exc\n\n\ndef _tiers_required(profile: ModelProfile) -> dict[Tier, list[str]]:\n if profile == ModelProfile.MAX:\n return {Tier.HIGH: list(AGENT_TIERS)}\n if profile == ModelProfile.TEST:\n return {Tier.LOW: list(AGENT_TIERS)}\n grouped: dict[Tier, list[str]] = {}\n for role, tier in AGENT_TIERS.items():\n grouped.setdefault(tier, []).append(role)\n return grouped\n\n\ndef _validate_chain_coverage(credentials: Credentials, profile: ModelProfile) -> None:\n \"\"\"Fail fast when ``credentials`` resolve to an EMPTY chain for any\n tier the factory must serve under ``profile``.\n\n ``LLMModelMapping.from_credentials_and_profile`` silently skips a role\n whose resolved chain is empty (no configured AuthMethod has a\n ``(method, tier)`` entry in ``METHOD_MODELS``). Downstream this surfaces\n as a confusing ``KeyError`` at ``get_model`` time or a primary-less\n ``ModelFallbackMiddleware([])``. Surface it once, here, with the\n offending tier(s) + the configured inventory so the operator can fix\n config immediately rather than chase a runtime failure.\n \"\"\"\n empty: list[tuple[Tier, list[str]]] = [\n (tier, roles)\n for tier, roles in _tiers_required(profile).items()\n if not resolve_chain(tier, credentials)\n ]\n if not empty:\n return\n details = \"; \".join(\n f\"{tier.value} (roles: {', '.join(sorted(roles))})\" for tier, roles in empty\n )\n inventory = \", \".join(m.value for m in credentials.methods) or \"<none>\"\n tier_list = \", \".join(tier.value for tier, _ in empty)\n raise ValueError(\n f\"LLMFactory: no model available for tier(s) [{tier_list}] under \"\n f\"profile {profile.value!r}. Empty chains: {details}. Configured \"\n f\"credentials: [{inventory}]. Add a credential whose AuthMethod \"\n f\"has an entry for the listed tier(s) in METHOD_MODELS \"\n f\"(packages/decepticon-core/decepticon_core/types/llm.py), or \"\n f\"adjust DECEPTICON_AUTH_PRIORITY / DECEPTICON_MODEL_PROFILE.\"\n )\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n raw_profile = profile if profile is not None else self._resolve_profile()\n resolved_profile = ModelProfile(raw_profile)\n _validate_chain_coverage(creds, resolved_profile)\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n _redact_url_credentials(self._proxy.url),\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "61775c6ed96c3dcbfe6913e714ebaa60e60376da": {"t": 1785755396.4517686, "n": 67594, "h": "f06faf3f7ee617f0", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nimport re\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any, Literal\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AGENT_TIERS,\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n Tier,\n resolve_chain,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n# `.env.example` documents the pydantic-settings nested form\n# (``DECEPTICON_LLM__TIMEOUT``) because that's what the rest of the\n# settings tree uses (``LLMConfig.timeout`` → httpx transport). The\n# whole-coroutine guard below is a separate piece of plumbing — but\n# from the user's point of view there is only one \"LLM timeout\" knob,\n# so we honor the documented name too. Precedence:\n# ``DECEPTICON_LLM_TIMEOUT_SECONDS`` (explicit, whole-coroutine) >\n# ``DECEPTICON_LLM__TIMEOUT`` (the published env knob) > default.\nLLM_TIMEOUT_ENV_ALIAS = \"DECEPTICON_LLM__TIMEOUT\"\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env >\n ``DECEPTICON_LLM__TIMEOUT`` env (the pydantic-settings nested form\n advertised in ``.env.example``) > default ``600``. The alias is what\n users actually set when following the published docs — without it,\n changing the documented knob silently has no effect on the whole-\n coroutine guard and long generations still trip the 600 s default\n (see issue #540).\n\n Rejects non-positive or non-numeric env values with ``ValueError``\n so misconfiguration fails loudly rather than silently disabling the\n guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n source = LLM_TIMEOUT_ENV\n if not raw:\n raw = os.getenv(LLM_TIMEOUT_ENV_ALIAS, \"\").strip()\n source = LLM_TIMEOUT_ENV_ALIAS\n if raw:\n try:\n value = float(raw)\n except ValueError as exc:\n raise ValueError(f\"{source} must be a number (got {raw!r})\") from exc\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{source} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Placed\n # after the first-party API providers and before the local endpoints:\n # a hosted gateway is a reasonable fallback but shouldn't preempt a\n # direct vendor key the user also configured.\n AuthMethod.OPENCODE_API,\n AuthMethod.VERCEL_GATEWAY_API,\n AuthMethod.ZENMUX_API,\n AuthMethod.NANOGPT_API,\n AuthMethod.VENICE_API,\n AuthMethod.SYNTHETIC_API,\n AuthMethod.HUGGINGFACE_API,\n AuthMethod.QIANFAN_API,\n AuthMethod.CLOUDFLARE_GATEWAY_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Each is\n # detected by the presence of a non-placeholder bearer key. The base\n # URL is fixed per gateway (see config/litellm.yaml) except Cloudflare,\n # whose per-account CLOUDFLARE_AI_GATEWAY_API_BASE must also be set —\n # documented in .env.example; a key without the base 404s at call time.\n AuthMethod.OPENCODE_API: \"OPENCODE_API_KEY\",\n AuthMethod.VERCEL_GATEWAY_API: \"VERCEL_AI_GATEWAY_API_KEY\",\n AuthMethod.HUGGINGFACE_API: \"HF_TOKEN\",\n AuthMethod.VENICE_API: \"VENICE_API_KEY\",\n AuthMethod.NANOGPT_API: \"NANOGPT_API_KEY\",\n AuthMethod.SYNTHETIC_API: \"SYNTHETIC_API_KEY\",\n AuthMethod.ZENMUX_API: \"ZENMUX_API_KEY\",\n AuthMethod.QIANFAN_API: \"QIANFAN_API_KEY\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"CLOUDFLARE_AI_GATEWAY_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n# Env vars each LiteLLM OAuth handler accepts as a credential *in place of*\n# its on-disk token file (see config/*_handler.py ``_load_tokens`` /\n# ``_env_override_tokens`` / ``_resolve_source_token``). A non-empty value in\n# any of these means the handler can authenticate without a file, so the\n# method must still count as configured. Codex (OPENAI_OAUTH) exposes no\n# env-token override — only the file-path env vars already covered by\n# ``_OAUTH_CREDENTIAL_PATHS`` — so it is intentionally absent here.\n_OAUTH_ENV_CREDENTIALS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\"ANTHROPIC_OAUTH_TOKEN\",),\n AuthMethod.GOOGLE_OAUTH: (\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"),\n AuthMethod.COPILOT_OAUTH: (\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"),\n AuthMethod.GROK_OAUTH: (\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"),\n AuthMethod.PERPLEXITY_OAUTH: (\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Any of ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint),\n ``OLLAMA_CLOUD_MODEL``, or a cloud API key\n (``OLLAMA_CLOUD_API_KEY`` / ``OLLAMA_API_KEY``) is enough to opt in.\n The key is included so a user who pasted only their key into\n `decepticon onboard` (relying on the default base + model) is still\n detected as a cloud user instead of silently falling through.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_API_KEY\", \"\").strip()\n or os.getenv(\"OLLAMA_API_KEY\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _oauth_env_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if any env var the handler accepts as a credential is set.\n\n Env-only OAuth setups (e.g. ``GEMINI_ACCESS_TOKEN`` with no token file)\n are valid: the LiteLLM handler authenticates straight from the env. Such\n a method must not be dropped from the fallback chain just because no file\n exists on disk.\n \"\"\"\n return any(os.getenv(env_var, \"\").strip() for env_var in _OAUTH_ENV_CREDENTIALS.get(method, ()))\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need the boolean intent AND a usable credential. The\n # credential may live in a token file OR in the handler's env-var\n # override — env-only setups are valid and must not be dropped.\n if not _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")):\n return False\n return _oauth_credentials_present(method) or _oauth_env_credentials_present(method)\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.OPENCODE_API: \"OpenCode Zen — API key\",\n AuthMethod.VERCEL_GATEWAY_API: \"Vercel AI Gateway — API key\",\n AuthMethod.HUGGINGFACE_API: \"Hugging Face Router — token\",\n AuthMethod.VENICE_API: \"Venice AI — API key\",\n AuthMethod.NANOGPT_API: \"NanoGPT — API key\",\n AuthMethod.SYNTHETIC_API: \"Synthetic — API key\",\n AuthMethod.ZENMUX_API: \"ZenMux — API key\",\n AuthMethod.QIANFAN_API: \"Baidu Qianfan (ERNIE) — API key\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"Cloudflare AI Gateway — API key + base URL\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n if _oauth_env_credentials_present(method):\n return f\"{flag}=true and credential present (env)\"\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n env_vars = \", \".join(_OAUTH_ENV_CREDENTIALS.get(method, ()))\n sources = paths or \"token\"\n if env_vars:\n sources = f\"{sources} or {env_vars}\"\n return f\"{flag}=true but no credential ({sources})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n result = super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n _log_served_model(self.model_name, result)\n return result\n\n async def ainvoke(self, *args, **kwargs):\n try:\n result = await call_with_timeout(\n super().ainvoke(*args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n _log_served_model(self.model_name, result)\n return result\n\n\ndef _log_served_model(requested: str, result: object) -> None:\n \"\"\"Best-effort attribution log: which provider/model the LiteLLM proxy\n actually routed to. When fallback fires (primary -> fallback) the\n requested model id and the served model id diverge — surfacing the\n delta lets operators trace cost/perf/debug back to the real upstream.\n\n Never raise from this path: attribution is observability, not control\n flow. Any extraction failure is swallowed silently.\n \"\"\"\n try:\n metadata = getattr(result, \"response_metadata\", None) or {}\n served = (\n metadata.get(\"model_name\")\n or metadata.get(\"model\")\n or getattr(result, \"model_name\", None)\n )\n if served and served != requested:\n log.info(\"llm.attribution requested=%s served=%s\", requested, served)\n elif served:\n log.debug(\"llm.attribution requested=%s served=%s\", requested, served)\n except Exception:\n pass\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\n# Patterns matching secret-shaped substrings that providers may echo back\n# inside HTTP error bodies. ``str(exc)`` for LiteLLM/openai errors is built\n# from that body, so it can reflect the Authorization/x-api-key/Bearer\n# credential we sent. Each pattern below scrubs one shape before the text is\n# interpolated into a user-facing RuntimeError that lands in CLI output/logs.\n_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (\n # \"Bearer <token>\" auth scheme (RFC 6750), case-insensitive.\n (re.compile(r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"), \"Bearer [REDACTED]\"),\n # Provider key prefixes: Anthropic sk-ant-* and OpenAI-style sk-* (any\n # remaining long token after the prefix).\n (re.compile(r\"\\bsk-(?:ant-)?[\\w.\\-]{8,}\"), \"[REDACTED]\"),\n # Header/field values for authorization / api_key / x-api-key, whether\n # JSON (\"authorization\": \"...\") or kwarg (api_key=...). Keeps the key\n # name so the guidance stays readable; only the value is scrubbed.\n (\n re.compile(\n r\"(?i)(['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*)\"\n r\"(['\\\"]?)[^'\\\"\\s,}]+\\2\"\n ),\n r\"\\1\\2[REDACTED]\\2\",\n ),\n # Generic long opaque tokens (>=24 chars of key-ish alphabet) that survive\n # the targeted passes above — e.g. provider keys without an sk- prefix.\n (re.compile(r\"\\b[A-Za-z0-9_\\-]{24,}\\b\"), \"[REDACTED]\"),\n)\n\n\ndef _redact_url_credentials(url: str) -> str:\n \"\"\"Strip ``user:pass@`` userinfo from a URL before logging.\n\n Operators may configure the proxy URL with embedded credentials\n (``http://user:pass@litellm:4000``); those must not reach INFO logs.\n \"\"\"\n from urllib.parse import urlsplit, urlunsplit\n\n try:\n parts = urlsplit(url)\n except ValueError:\n return url\n if not parts.scheme or \"@\" not in parts.netloc:\n return url\n host = parts.hostname or \"\"\n if parts.port is not None:\n host = f\"{host}:{parts.port}\"\n return urlunsplit((parts.scheme, f\"***@{host}\", parts.path, parts.query, parts.fragment))\n\n\ndef _redact_secrets(text: str) -> str:\n \"\"\"Scrub credential-shaped substrings from upstream error text.\n\n LiteLLM/openai surface the provider's raw HTTP response body via\n ``str(exc)``; that body can echo the ``Authorization``/``x-api-key``/\n ``Bearer`` value we sent. Redacting here keeps the actionable guidance\n (status code, model id, human hint) intact while preventing the secret\n from leaking into CLI output and logs.\n \"\"\"\n for pattern, replacement in _SECRET_PATTERNS:\n text = pattern.sub(replacement, text)\n return text\n\n\nProviderErrorClass = Literal[\"retryable\", \"fatal\"]\n\n# HTTP status → class. 408/425/429 + 5xx are worth a retry / fallback hop;\n# 4xx auth/config (400/401/403/404) won't be fixed by another attempt.\n_RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})\n_FATAL_STATUS = frozenset({400, 401, 403, 404})\n\n# Last-resort message scrape when the exception lacks ``status_code`` —\n# LiteLLM/openai surface the upstream status only in the body text.\n_STATUS_IN_MSG = re.compile(r\"(?:error\\s*code|status(?:_code)?|http)\\D{0,8}(\\d{3})\", re.I)\n\n\ndef _classify_provider_error(exc: BaseException) -> ProviderErrorClass:\n \"\"\"Tag a provider exception as ``retryable`` or ``fatal``.\n\n Inspection order (cheapest signal first):\n 1. Our own ``LLMTimeoutError`` and httpx transport errors → retryable.\n 2. ``status_code`` attribute (openai.APIStatusError shape).\n 3. ``response.status_code`` (httpx.HTTPStatusError shape).\n 4. Regex over ``str(exc)`` — LiteLLM nests upstream status in the body.\n\n Default for unrecognised shapes is ``retryable`` so we preserve today's\n behavior (LiteLLM ``num_retries`` keeps spinning). Only flip to ``fatal``\n on a positive 4xx signal.\n \"\"\"\n if isinstance(exc, LLMTimeoutError):\n return \"retryable\"\n if isinstance(exc, (httpx.TransportError, httpx.TimeoutException)):\n return \"retryable\"\n\n status = getattr(exc, \"status_code\", None)\n if status is None:\n response = getattr(exc, \"response\", None)\n status = getattr(response, \"status_code\", None)\n if isinstance(status, int):\n if status in _FATAL_STATUS:\n return \"fatal\"\n if status in _RETRYABLE_STATUS:\n return \"retryable\"\n\n match = _STATUS_IN_MSG.search(str(exc))\n if match is not None:\n code = int(match.group(1))\n if code in _FATAL_STATUS:\n return \"fatal\"\n if code in _RETRYABLE_STATUS:\n return \"retryable\"\n\n return \"retryable\"\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {_redact_secrets(str(exc))}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n fatal_prefix = (\n \"non-retryable provider error: \" if _classify_provider_error(exc) == \"fatal\" else \"\"\n )\n # Match on the raw text (status codes / keywords are not secret-shaped),\n # but interpolate the scrubbed copy so an echoed credential never reaches\n # the user-facing message — see _redact_secrets.\n safe_msg = _redact_secrets(msg)\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {safe_msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n\ndef _tiers_required(profile: ModelProfile) -> dict[Tier, list[str]]:\n if profile == ModelProfile.MAX:\n return {Tier.HIGH: list(AGENT_TIERS)}\n if profile == ModelProfile.TEST:\n return {Tier.LOW: list(AGENT_TIERS)}\n grouped: dict[Tier, list[str]] = {}\n for role, tier in AGENT_TIERS.items():\n grouped.setdefault(tier, []).append(role)\n return grouped\n\n\ndef _validate_chain_coverage(credentials: Credentials, profile: ModelProfile) -> None:\n \"\"\"Fail fast when ``credentials`` resolve to an EMPTY chain for any\n tier the factory must serve under ``profile``.\n\n ``LLMModelMapping.from_credentials_and_profile`` silently skips a role\n whose resolved chain is empty (no configured AuthMethod has a\n ``(method, tier)`` entry in ``METHOD_MODELS``). Downstream this surfaces\n as a confusing ``KeyError`` at ``get_model`` time or a primary-less\n ``ModelFallbackMiddleware([])``. Surface it once, here, with the\n offending tier(s) + the configured inventory so the operator can fix\n config immediately rather than chase a runtime failure.\n \"\"\"\n empty: list[tuple[Tier, list[str]]] = [\n (tier, roles)\n for tier, roles in _tiers_required(profile).items()\n if not resolve_chain(tier, credentials)\n ]\n if not empty:\n return\n details = \"; \".join(\n f\"{tier.value} (roles: {', '.join(sorted(roles))})\" for tier, roles in empty\n )\n inventory = \", \".join(m.value for m in credentials.methods) or \"<none>\"\n tier_list = \", \".join(tier.value for tier, _ in empty)\n raise ValueError(\n f\"LLMFactory: no model available for tier(s) [{tier_list}] under \"\n f\"profile {profile.value!r}. Empty chains: {details}. Configured \"\n f\"credentials: [{inventory}]. Add a credential whose AuthMethod \"\n f\"has an entry for the listed tier(s) in METHOD_MODELS \"\n f\"(packages/decepticon-core/decepticon_core/types/llm.py), or \"\n f\"adjust DECEPTICON_AUTH_PRIORITY / DECEPTICON_MODEL_PROFILE.\"\n )\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n raw_profile = profile if profile is not None else self._resolve_profile()\n resolved_profile = ModelProfile(raw_profile)\n _validate_chain_coverage(creds, resolved_profile)\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n _redact_url_credentials(self._proxy.url),\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "4bbce381e4df4072020ff7aac90d4be0e7a6367e": {"t": 1785755398.1497462, "n": 67624, "h": "eb7a5ab2eab2231e", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nimport re\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any, Literal\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AGENT_TIERS,\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n Tier,\n resolve_chain,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n# `.env.example` documents the pydantic-settings nested form\n# (``DECEPTICON_LLM__TIMEOUT``) because that's what the rest of the\n# settings tree uses (``LLMConfig.timeout`` → httpx transport). The\n# whole-coroutine guard below is a separate piece of plumbing — but\n# from the user's point of view there is only one \"LLM timeout\" knob,\n# so we honor the documented name too. Precedence:\n# ``DECEPTICON_LLM_TIMEOUT_SECONDS`` (explicit, whole-coroutine) >\n# ``DECEPTICON_LLM__TIMEOUT`` (the published env knob) > default.\nLLM_TIMEOUT_ENV_ALIAS = \"DECEPTICON_LLM__TIMEOUT\"\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env >\n ``DECEPTICON_LLM__TIMEOUT`` env (the pydantic-settings nested form\n advertised in ``.env.example``) > default ``600``. The alias is what\n users actually set when following the published docs — without it,\n changing the documented knob silently has no effect on the whole-\n coroutine guard and long generations still trip the 600 s default\n (see issue #540).\n\n Rejects non-positive or non-numeric env values with ``ValueError``\n so misconfiguration fails loudly rather than silently disabling the\n guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n source = LLM_TIMEOUT_ENV\n if not raw:\n raw = os.getenv(LLM_TIMEOUT_ENV_ALIAS, \"\").strip()\n source = LLM_TIMEOUT_ENV_ALIAS\n if raw:\n try:\n value = float(raw)\n except ValueError as exc:\n raise ValueError(\n f\"{source} must be a number (got {raw!r})\"\n ) from exc\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{source} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Placed\n # after the first-party API providers and before the local endpoints:\n # a hosted gateway is a reasonable fallback but shouldn't preempt a\n # direct vendor key the user also configured.\n AuthMethod.OPENCODE_API,\n AuthMethod.VERCEL_GATEWAY_API,\n AuthMethod.ZENMUX_API,\n AuthMethod.NANOGPT_API,\n AuthMethod.VENICE_API,\n AuthMethod.SYNTHETIC_API,\n AuthMethod.HUGGINGFACE_API,\n AuthMethod.QIANFAN_API,\n AuthMethod.CLOUDFLARE_GATEWAY_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Each is\n # detected by the presence of a non-placeholder bearer key. The base\n # URL is fixed per gateway (see config/litellm.yaml) except Cloudflare,\n # whose per-account CLOUDFLARE_AI_GATEWAY_API_BASE must also be set —\n # documented in .env.example; a key without the base 404s at call time.\n AuthMethod.OPENCODE_API: \"OPENCODE_API_KEY\",\n AuthMethod.VERCEL_GATEWAY_API: \"VERCEL_AI_GATEWAY_API_KEY\",\n AuthMethod.HUGGINGFACE_API: \"HF_TOKEN\",\n AuthMethod.VENICE_API: \"VENICE_API_KEY\",\n AuthMethod.NANOGPT_API: \"NANOGPT_API_KEY\",\n AuthMethod.SYNTHETIC_API: \"SYNTHETIC_API_KEY\",\n AuthMethod.ZENMUX_API: \"ZENMUX_API_KEY\",\n AuthMethod.QIANFAN_API: \"QIANFAN_API_KEY\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"CLOUDFLARE_AI_GATEWAY_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n# Env vars each LiteLLM OAuth handler accepts as a credential *in place of*\n# its on-disk token file (see config/*_handler.py ``_load_tokens`` /\n# ``_env_override_tokens`` / ``_resolve_source_token``). A non-empty value in\n# any of these means the handler can authenticate without a file, so the\n# method must still count as configured. Codex (OPENAI_OAUTH) exposes no\n# env-token override — only the file-path env vars already covered by\n# ``_OAUTH_CREDENTIAL_PATHS`` — so it is intentionally absent here.\n_OAUTH_ENV_CREDENTIALS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\"ANTHROPIC_OAUTH_TOKEN\",),\n AuthMethod.GOOGLE_OAUTH: (\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"),\n AuthMethod.COPILOT_OAUTH: (\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"),\n AuthMethod.GROK_OAUTH: (\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"),\n AuthMethod.PERPLEXITY_OAUTH: (\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Any of ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint),\n ``OLLAMA_CLOUD_MODEL``, or a cloud API key\n (``OLLAMA_CLOUD_API_KEY`` / ``OLLAMA_API_KEY``) is enough to opt in.\n The key is included so a user who pasted only their key into\n `decepticon onboard` (relying on the default base + model) is still\n detected as a cloud user instead of silently falling through.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_API_KEY\", \"\").strip()\n or os.getenv(\"OLLAMA_API_KEY\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _oauth_env_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if any env var the handler accepts as a credential is set.\n\n Env-only OAuth setups (e.g. ``GEMINI_ACCESS_TOKEN`` with no token file)\n are valid: the LiteLLM handler authenticates straight from the env. Such\n a method must not be dropped from the fallback chain just because no file\n exists on disk.\n \"\"\"\n return any(os.getenv(env_var, \"\").strip() for env_var in _OAUTH_ENV_CREDENTIALS.get(method, ()))\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need the boolean intent AND a usable credential. The\n # credential may live in a token file OR in the handler's env-var\n # override — env-only setups are valid and must not be dropped.\n if not _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")):\n return False\n return _oauth_credentials_present(method) or _oauth_env_credentials_present(method)\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.OPENCODE_API: \"OpenCode Zen — API key\",\n AuthMethod.VERCEL_GATEWAY_API: \"Vercel AI Gateway — API key\",\n AuthMethod.HUGGINGFACE_API: \"Hugging Face Router — token\",\n AuthMethod.VENICE_API: \"Venice AI — API key\",\n AuthMethod.NANOGPT_API: \"NanoGPT — API key\",\n AuthMethod.SYNTHETIC_API: \"Synthetic — API key\",\n AuthMethod.ZENMUX_API: \"ZenMux — API key\",\n AuthMethod.QIANFAN_API: \"Baidu Qianfan (ERNIE) — API key\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"Cloudflare AI Gateway — API key + base URL\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n if _oauth_env_credentials_present(method):\n return f\"{flag}=true and credential present (env)\"\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n env_vars = \", \".join(_OAUTH_ENV_CREDENTIALS.get(method, ()))\n sources = paths or \"token\"\n if env_vars:\n sources = f\"{sources} or {env_vars}\"\n return f\"{flag}=true but no credential ({sources})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n result = super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n _log_served_model(self.model_name, result)\n return result\n\n async def ainvoke(self, *args, **kwargs):\n try:\n result = await call_with_timeout(\n super().ainvoke(*args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n _log_served_model(self.model_name, result)\n return result\n\n\ndef _log_served_model(requested: str, result: object) -> None:\n \"\"\"Best-effort attribution log: which provider/model the LiteLLM proxy\n actually routed to. When fallback fires (primary -> fallback) the\n requested model id and the served model id diverge — surfacing the\n delta lets operators trace cost/perf/debug back to the real upstream.\n\n Never raise from this path: attribution is observability, not control\n flow. Any extraction failure is swallowed silently.\n \"\"\"\n try:\n metadata = getattr(result, \"response_metadata\", None) or {}\n served = (\n metadata.get(\"model_name\")\n or metadata.get(\"model\")\n or getattr(result, \"model_name\", None)\n )\n if served and served != requested:\n log.info(\"llm.attribution requested=%s served=%s\", requested, served)\n elif served:\n log.debug(\"llm.attribution requested=%s served=%s\", requested, served)\n except Exception:\n pass\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\n# Patterns matching secret-shaped substrings that providers may echo back\n# inside HTTP error bodies. ``str(exc)`` for LiteLLM/openai errors is built\n# from that body, so it can reflect the Authorization/x-api-key/Bearer\n# credential we sent. Each pattern below scrubs one shape before the text is\n# interpolated into a user-facing RuntimeError that lands in CLI output/logs.\n_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (\n # \"Bearer <token>\" auth scheme (RFC 6750), case-insensitive.\n (re.compile(r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"), \"Bearer [REDACTED]\"),\n # Provider key prefixes: Anthropic sk-ant-* and OpenAI-style sk-* (any\n # remaining long token after the prefix).\n (re.compile(r\"\\bsk-(?:ant-)?[\\w.\\-]{8,}\"), \"[REDACTED]\"),\n # Header/field values for authorization / api_key / x-api-key, whether\n # JSON (\"authorization\": \"...\") or kwarg (api_key=...). Keeps the key\n # name so the guidance stays readable; only the value is scrubbed.\n (\n re.compile(\n r\"(?i)(['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*)\"\n r\"(['\\\"]?)[^'\\\"\\s,}]+\\2\"\n ),\n r\"\\1\\2[REDACTED]\\2\",\n ),\n # Generic long opaque tokens (>=24 chars of key-ish alphabet) that survive\n # the targeted passes above — e.g. provider keys without an sk- prefix.\n (re.compile(r\"\\b[A-Za-z0-9_\\-]{24,}\\b\"), \"[REDACTED]\"),\n)\n\n\ndef _redact_url_credentials(url: str) -> str:\n \"\"\"Strip ``user:pass@`` userinfo from a URL before logging.\n\n Operators may configure the proxy URL with embedded credentials\n (``http://user:pass@litellm:4000``); those must not reach INFO logs.\n \"\"\"\n from urllib.parse import urlsplit, urlunsplit\n\n try:\n parts = urlsplit(url)\n except ValueError:\n return url\n if not parts.scheme or \"@\" not in parts.netloc:\n return url\n host = parts.hostname or \"\"\n if parts.port is not None:\n host = f\"{host}:{parts.port}\"\n return urlunsplit((parts.scheme, f\"***@{host}\", parts.path, parts.query, parts.fragment))\n\n\ndef _redact_secrets(text: str) -> str:\n \"\"\"Scrub credential-shaped substrings from upstream error text.\n\n LiteLLM/openai surface the provider's raw HTTP response body via\n ``str(exc)``; that body can echo the ``Authorization``/``x-api-key``/\n ``Bearer`` value we sent. Redacting here keeps the actionable guidance\n (status code, model id, human hint) intact while preventing the secret\n from leaking into CLI output and logs.\n \"\"\"\n for pattern, replacement in _SECRET_PATTERNS:\n text = pattern.sub(replacement, text)\n return text\n\n\nProviderErrorClass = Literal[\"retryable\", \"fatal\"]\n\n# HTTP status → class. 408/425/429 + 5xx are worth a retry / fallback hop;\n# 4xx auth/config (400/401/403/404) won't be fixed by another attempt.\n_RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})\n_FATAL_STATUS = frozenset({400, 401, 403, 404})\n\n# Last-resort message scrape when the exception lacks ``status_code`` —\n# LiteLLM/openai surface the upstream status only in the body text.\n_STATUS_IN_MSG = re.compile(r\"(?:error\\s*code|status(?:_code)?|http)\\D{0,8}(\\d{3})\", re.I)\n\n\ndef _classify_provider_error(exc: BaseException) -> ProviderErrorClass:\n \"\"\"Tag a provider exception as ``retryable`` or ``fatal``.\n\n Inspection order (cheapest signal first):\n 1. Our own ``LLMTimeoutError`` and httpx transport errors → retryable.\n 2. ``status_code`` attribute (openai.APIStatusError shape).\n 3. ``response.status_code`` (httpx.HTTPStatusError shape).\n 4. Regex over ``str(exc)`` — LiteLLM nests upstream status in the body.\n\n Default for unrecognised shapes is ``retryable`` so we preserve today's\n behavior (LiteLLM ``num_retries`` keeps spinning). Only flip to ``fatal``\n on a positive 4xx signal.\n \"\"\"\n if isinstance(exc, LLMTimeoutError):\n return \"retryable\"\n if isinstance(exc, (httpx.TransportError, httpx.TimeoutException)):\n return \"retryable\"\n\n status = getattr(exc, \"status_code\", None)\n if status is None:\n response = getattr(exc, \"response\", None)\n status = getattr(response, \"status_code\", None)\n if isinstance(status, int):\n if status in _FATAL_STATUS:\n return \"fatal\"\n if status in _RETRYABLE_STATUS:\n return \"retryable\"\n\n match = _STATUS_IN_MSG.search(str(exc))\n if match is not None:\n code = int(match.group(1))\n if code in _FATAL_STATUS:\n return \"fatal\"\n if code in _RETRYABLE_STATUS:\n return \"retryable\"\n\n return \"retryable\"\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {_redact_secrets(str(exc))}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n fatal_prefix = (\n \"non-retryable provider error: \" if _classify_provider_error(exc) == \"fatal\" else \"\"\n )\n # Match on the raw text (status codes / keywords are not secret-shaped),\n # but interpolate the scrubbed copy so an echoed credential never reaches\n # the user-facing message — see _redact_secrets.\n safe_msg = _redact_secrets(msg)\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {safe_msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n\ndef _tiers_required(profile: ModelProfile) -> dict[Tier, list[str]]:\n if profile == ModelProfile.MAX:\n return {Tier.HIGH: list(AGENT_TIERS)}\n if profile == ModelProfile.TEST:\n return {Tier.LOW: list(AGENT_TIERS)}\n grouped: dict[Tier, list[str]] = {}\n for role, tier in AGENT_TIERS.items():\n grouped.setdefault(tier, []).append(role)\n return grouped\n\n\ndef _validate_chain_coverage(credentials: Credentials, profile: ModelProfile) -> None:\n \"\"\"Fail fast when ``credentials`` resolve to an EMPTY chain for any\n tier the factory must serve under ``profile``.\n\n ``LLMModelMapping.from_credentials_and_profile`` silently skips a role\n whose resolved chain is empty (no configured AuthMethod has a\n ``(method, tier)`` entry in ``METHOD_MODELS``). Downstream this surfaces\n as a confusing ``KeyError`` at ``get_model`` time or a primary-less\n ``ModelFallbackMiddleware([])``. Surface it once, here, with the\n offending tier(s) + the configured inventory so the operator can fix\n config immediately rather than chase a runtime failure.\n \"\"\"\n empty: list[tuple[Tier, list[str]]] = [\n (tier, roles)\n for tier, roles in _tiers_required(profile).items()\n if not resolve_chain(tier, credentials)\n ]\n if not empty:\n return\n details = \"; \".join(\n f\"{tier.value} (roles: {', '.join(sorted(roles))})\" for tier, roles in empty\n )\n inventory = \", \".join(m.value for m in credentials.methods) or \"<none>\"\n tier_list = \", \".join(tier.value for tier, _ in empty)\n raise ValueError(\n f\"LLMFactory: no model available for tier(s) [{tier_list}] under \"\n f\"profile {profile.value!r}. Empty chains: {details}. Configured \"\n f\"credentials: [{inventory}]. Add a credential whose AuthMethod \"\n f\"has an entry for the listed tier(s) in METHOD_MODELS \"\n f\"(packages/decepticon-core/decepticon_core/types/llm.py), or \"\n f\"adjust DECEPTICON_AUTH_PRIORITY / DECEPTICON_MODEL_PROFILE.\"\n )\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n raw_profile = profile if profile is not None else self._resolve_profile()\n resolved_profile = ModelProfile(raw_profile)\n _validate_chain_coverage(creds, resolved_profile)\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n _redact_url_credentials(self._proxy.url),\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "4a57b1ac2067001acf67edf726a47792f81298da": {"t": 1785755424.0184095, "n": 65252, "h": "d0318749b0063f50", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nimport re\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any, Literal\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AGENT_TIERS,\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n Tier,\n resolve_chain,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env > default ``600``.\n Rejects non-positive or non-numeric env values with ``ValueError`` so\n misconfiguration fails loudly rather than silently disabling the guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n if raw:\n value = float(raw)\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{LLM_TIMEOUT_ENV} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Placed\n # after the first-party API providers and before the local endpoints:\n # a hosted gateway is a reasonable fallback but shouldn't preempt a\n # direct vendor key the user also configured.\n AuthMethod.OPENCODE_API,\n AuthMethod.VERCEL_GATEWAY_API,\n AuthMethod.ZENMUX_API,\n AuthMethod.NANOGPT_API,\n AuthMethod.VENICE_API,\n AuthMethod.SYNTHETIC_API,\n AuthMethod.HUGGINGFACE_API,\n AuthMethod.QIANFAN_API,\n AuthMethod.CLOUDFLARE_GATEWAY_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Each is\n # detected by the presence of a non-placeholder bearer key. The base\n # URL is fixed per gateway (see config/litellm.yaml) except Cloudflare,\n # whose per-account CLOUDFLARE_AI_GATEWAY_API_BASE must also be set —\n # documented in .env.example; a key without the base 404s at call time.\n AuthMethod.OPENCODE_API: \"OPENCODE_API_KEY\",\n AuthMethod.VERCEL_GATEWAY_API: \"VERCEL_AI_GATEWAY_API_KEY\",\n AuthMethod.HUGGINGFACE_API: \"HF_TOKEN\",\n AuthMethod.VENICE_API: \"VENICE_API_KEY\",\n AuthMethod.NANOGPT_API: \"NANOGPT_API_KEY\",\n AuthMethod.SYNTHETIC_API: \"SYNTHETIC_API_KEY\",\n AuthMethod.ZENMUX_API: \"ZENMUX_API_KEY\",\n AuthMethod.QIANFAN_API: \"QIANFAN_API_KEY\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"CLOUDFLARE_AI_GATEWAY_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n# Env vars each LiteLLM OAuth handler accepts as a credential *in place of*\n# its on-disk token file (see config/*_handler.py ``_load_tokens`` /\n# ``_env_override_tokens`` / ``_resolve_source_token``). A non-empty value in\n# any of these means the handler can authenticate without a file, so the\n# method must still count as configured. Codex (OPENAI_OAUTH) exposes no\n# env-token override — only the file-path env vars already covered by\n# ``_OAUTH_CREDENTIAL_PATHS`` — so it is intentionally absent here.\n_OAUTH_ENV_CREDENTIALS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\"ANTHROPIC_OAUTH_TOKEN\",),\n AuthMethod.GOOGLE_OAUTH: (\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"),\n AuthMethod.COPILOT_OAUTH: (\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"),\n AuthMethod.GROK_OAUTH: (\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"),\n AuthMethod.PERPLEXITY_OAUTH: (\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Any of ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint),\n ``OLLAMA_CLOUD_MODEL``, or a cloud API key\n (``OLLAMA_CLOUD_API_KEY`` / ``OLLAMA_API_KEY``) is enough to opt in.\n The key is included so a user who pasted only their key into\n `decepticon onboard` (relying on the default base + model) is still\n detected as a cloud user instead of silently falling through.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_API_KEY\", \"\").strip()\n or os.getenv(\"OLLAMA_API_KEY\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _oauth_env_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if any env var the handler accepts as a credential is set.\n\n Env-only OAuth setups (e.g. ``GEMINI_ACCESS_TOKEN`` with no token file)\n are valid: the LiteLLM handler authenticates straight from the env. Such\n a method must not be dropped from the fallback chain just because no file\n exists on disk.\n \"\"\"\n return any(os.getenv(env_var, \"\").strip() for env_var in _OAUTH_ENV_CREDENTIALS.get(method, ()))\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need the boolean intent AND a usable credential. The\n # credential may live in a token file OR in the handler's env-var\n # override — env-only setups are valid and must not be dropped.\n if not _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")):\n return False\n return _oauth_credentials_present(method) or _oauth_env_credentials_present(method)\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.OPENCODE_API: \"OpenCode Zen — API key\",\n AuthMethod.VERCEL_GATEWAY_API: \"Vercel AI Gateway — API key\",\n AuthMethod.HUGGINGFACE_API: \"Hugging Face Router — token\",\n AuthMethod.VENICE_API: \"Venice AI — API key\",\n AuthMethod.NANOGPT_API: \"NanoGPT — API key\",\n AuthMethod.SYNTHETIC_API: \"Synthetic — API key\",\n AuthMethod.ZENMUX_API: \"ZenMux — API key\",\n AuthMethod.QIANFAN_API: \"Baidu Qianfan (ERNIE) — API key\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"Cloudflare AI Gateway — API key + base URL\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n if _oauth_env_credentials_present(method):\n return f\"{flag}=true and credential present (env)\"\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n env_vars = \", \".join(_OAUTH_ENV_CREDENTIALS.get(method, ()))\n sources = paths or \"token\"\n if env_vars:\n sources = f\"{sources} or {env_vars}\"\n return f\"{flag}=true but no credential ({sources})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n return super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n async def ainvoke(self, *args, **kwargs):\n try:\n return await call_with_timeout(\n super().ainvoke(*args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\n# Patterns matching secret-shaped substrings that providers may echo back\n# inside HTTP error bodies. ``str(exc)`` for LiteLLM/openai errors is built\n# from that body, so it can reflect the Authorization/x-api-key/Bearer\n# credential we sent. Each pattern below scrubs one shape before the text is\n# interpolated into a user-facing RuntimeError that lands in CLI output/logs.\n_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (\n # \"Bearer <token>\" auth scheme (RFC 6750), case-insensitive.\n (re.compile(r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"), \"Bearer [REDACTED]\"),\n # Provider key prefixes: Anthropic sk-ant-* and OpenAI-style sk-* (any\n # remaining long token after the prefix).\n (re.compile(r\"\\bsk-(?:ant-)?[\\w.\\-]{8,}\"), \"[REDACTED]\"),\n # Header/field values for authorization / api_key / x-api-key, whether\n # JSON (\"authorization\": \"...\") or kwarg (api_key=...). Keeps the key\n # name so the guidance stays readable; only the value is scrubbed.\n (\n re.compile(\n r\"(?i)(['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*)\"\n r\"(['\\\"]?)[^'\\\"\\s,}]+\\2\"\n ),\n r\"\\1\\2[REDACTED]\\2\",\n ),\n # Generic long opaque tokens (>=24 chars of key-ish alphabet) that survive\n # the targeted passes above — e.g. provider keys without an sk- prefix.\n (re.compile(r\"\\b[A-Za-z0-9_\\-]{24,}\\b\"), \"[REDACTED]\"),\n)\n\n\ndef _redact_url_credentials(url: str) -> str:\n \"\"\"Strip ``user:pass@`` userinfo from a URL before logging.\n\n Operators may configure the proxy URL with embedded credentials\n (``http://user:pass@litellm:4000``); those must not reach INFO logs.\n \"\"\"\n from urllib.parse import urlsplit, urlunsplit\n\n try:\n parts = urlsplit(url)\n except ValueError:\n return url\n if not parts.scheme or \"@\" not in parts.netloc:\n return url\n host = parts.hostname or \"\"\n if parts.port is not None:\n host = f\"{host}:{parts.port}\"\n return urlunsplit((parts.scheme, f\"***@{host}\", parts.path, parts.query, parts.fragment))\n\n\ndef _redact_secrets(text: str) -> str:\n \"\"\"Scrub credential-shaped substrings from upstream error text.\n\n LiteLLM/openai surface the provider's raw HTTP response body via\n ``str(exc)``; that body can echo the ``Authorization``/``x-api-key``/\n ``Bearer`` value we sent. Redacting here keeps the actionable guidance\n (status code, model id, human hint) intact while preventing the secret\n from leaking into CLI output and logs.\n \"\"\"\n for pattern, replacement in _SECRET_PATTERNS:\n text = pattern.sub(replacement, text)\n return text\n\n\nProviderErrorClass = Literal[\"retryable\", \"fatal\"]\n\n# HTTP status → class. 408/425/429 + 5xx are worth a retry / fallback hop;\n# 4xx auth/config (400/401/403/404) won't be fixed by another attempt.\n_RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})\n_FATAL_STATUS = frozenset({400, 401, 403, 404})\n\n# Last-resort message scrape when the exception lacks ``status_code`` —\n# LiteLLM/openai surface the upstream status only in the body text.\n_STATUS_IN_MSG = re.compile(r\"(?:error\\s*code|status(?:_code)?|http)\\D{0,8}(\\d{3})\", re.I)\n\n\ndef _classify_provider_error(exc: BaseException) -> ProviderErrorClass:\n \"\"\"Tag a provider exception as ``retryable`` or ``fatal``.\n\n Inspection order (cheapest signal first):\n 1. Our own ``LLMTimeoutError`` and httpx transport errors → retryable.\n 2. ``status_code`` attribute (openai.APIStatusError shape).\n 3. ``response.status_code`` (httpx.HTTPStatusError shape).\n 4. Regex over ``str(exc)`` — LiteLLM nests upstream status in the body.\n\n Default for unrecognised shapes is ``retryable`` so we preserve today's\n behavior (LiteLLM ``num_retries`` keeps spinning). Only flip to ``fatal``\n on a positive 4xx signal.\n \"\"\"\n if isinstance(exc, LLMTimeoutError):\n return \"retryable\"\n if isinstance(exc, (httpx.TransportError, httpx.TimeoutException)):\n return \"retryable\"\n\n status = getattr(exc, \"status_code\", None)\n if status is None:\n response = getattr(exc, \"response\", None)\n status = getattr(response, \"status_code\", None)\n if isinstance(status, int):\n if status in _FATAL_STATUS:\n return \"fatal\"\n if status in _RETRYABLE_STATUS:\n return \"retryable\"\n\n match = _STATUS_IN_MSG.search(str(exc))\n if match is not None:\n code = int(match.group(1))\n if code in _FATAL_STATUS:\n return \"fatal\"\n if code in _RETRYABLE_STATUS:\n return \"retryable\"\n\n return \"retryable\"\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {_redact_secrets(str(exc))}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n fatal_prefix = (\n \"non-retryable provider error: \" if _classify_provider_error(exc) == \"fatal\" else \"\"\n )\n # Match on the raw text (status codes / keywords are not secret-shaped),\n # but interpolate the scrubbed copy so an echoed credential never reaches\n # the user-facing message — see _redact_secrets.\n safe_msg = _redact_secrets(msg)\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {safe_msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n\ndef _tiers_required(profile: ModelProfile) -> dict[Tier, list[str]]:\n if profile == ModelProfile.MAX:\n return {Tier.HIGH: list(AGENT_TIERS)}\n if profile == ModelProfile.TEST:\n return {Tier.LOW: list(AGENT_TIERS)}\n grouped: dict[Tier, list[str]] = {}\n for role, tier in AGENT_TIERS.items():\n grouped.setdefault(tier, []).append(role)\n return grouped\n\n\ndef _validate_chain_coverage(credentials: Credentials, profile: ModelProfile) -> None:\n \"\"\"Fail fast when ``credentials`` resolve to an EMPTY chain for any\n tier the factory must serve under ``profile``.\n\n ``LLMModelMapping.from_credentials_and_profile`` silently skips a role\n whose resolved chain is empty (no configured AuthMethod has a\n ``(method, tier)`` entry in ``METHOD_MODELS``). Downstream this surfaces\n as a confusing ``KeyError`` at ``get_model`` time or a primary-less\n ``ModelFallbackMiddleware([])``. Surface it once, here, with the\n offending tier(s) + the configured inventory so the operator can fix\n config immediately rather than chase a runtime failure.\n \"\"\"\n empty: list[tuple[Tier, list[str]]] = [\n (tier, roles)\n for tier, roles in _tiers_required(profile).items()\n if not resolve_chain(tier, credentials)\n ]\n if not empty:\n return\n details = \"; \".join(\n f\"{tier.value} (roles: {', '.join(sorted(roles))})\" for tier, roles in empty\n )\n inventory = \", \".join(m.value for m in credentials.methods) or \"<none>\"\n tier_list = \", \".join(tier.value for tier, _ in empty)\n raise ValueError(\n f\"LLMFactory: no model available for tier(s) [{tier_list}] under \"\n f\"profile {profile.value!r}. Empty chains: {details}. Configured \"\n f\"credentials: [{inventory}]. Add a credential whose AuthMethod \"\n f\"has an entry for the listed tier(s) in METHOD_MODELS \"\n f\"(packages/decepticon-core/decepticon_core/types/llm.py), or \"\n f\"adjust DECEPTICON_AUTH_PRIORITY / DECEPTICON_MODEL_PROFILE.\"\n )\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n raw_profile = profile if profile is not None else self._resolve_profile()\n resolved_profile = ModelProfile(raw_profile)\n _validate_chain_coverage(creds, resolved_profile)\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n _redact_url_credentials(self._proxy.url),\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "3ff732579dfb186eb73e8f877cdf0b92e16ecd87": {"t": 1785755448.6129405, "n": 61373, "h": "311d39bc65604d8d", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nimport re\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env > default ``600``.\n Rejects non-positive or non-numeric env values with ``ValueError`` so\n misconfiguration fails loudly rather than silently disabling the guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n if raw:\n value = float(raw)\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{LLM_TIMEOUT_ENV} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Placed\n # after the first-party API providers and before the local endpoints:\n # a hosted gateway is a reasonable fallback but shouldn't preempt a\n # direct vendor key the user also configured.\n AuthMethod.OPENCODE_API,\n AuthMethod.VERCEL_GATEWAY_API,\n AuthMethod.ZENMUX_API,\n AuthMethod.NANOGPT_API,\n AuthMethod.VENICE_API,\n AuthMethod.SYNTHETIC_API,\n AuthMethod.HUGGINGFACE_API,\n AuthMethod.QIANFAN_API,\n AuthMethod.CLOUDFLARE_GATEWAY_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Each is\n # detected by the presence of a non-placeholder bearer key. The base\n # URL is fixed per gateway (see config/litellm.yaml) except Cloudflare,\n # whose per-account CLOUDFLARE_AI_GATEWAY_API_BASE must also be set —\n # documented in .env.example; a key without the base 404s at call time.\n AuthMethod.OPENCODE_API: \"OPENCODE_API_KEY\",\n AuthMethod.VERCEL_GATEWAY_API: \"VERCEL_AI_GATEWAY_API_KEY\",\n AuthMethod.HUGGINGFACE_API: \"HF_TOKEN\",\n AuthMethod.VENICE_API: \"VENICE_API_KEY\",\n AuthMethod.NANOGPT_API: \"NANOGPT_API_KEY\",\n AuthMethod.SYNTHETIC_API: \"SYNTHETIC_API_KEY\",\n AuthMethod.ZENMUX_API: \"ZENMUX_API_KEY\",\n AuthMethod.QIANFAN_API: \"QIANFAN_API_KEY\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"CLOUDFLARE_AI_GATEWAY_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n# Env vars each LiteLLM OAuth handler accepts as a credential *in place of*\n# its on-disk token file (see config/*_handler.py ``_load_tokens`` /\n# ``_env_override_tokens`` / ``_resolve_source_token``). A non-empty value in\n# any of these means the handler can authenticate without a file, so the\n# method must still count as configured. Codex (OPENAI_OAUTH) exposes no\n# env-token override — only the file-path env vars already covered by\n# ``_OAUTH_CREDENTIAL_PATHS`` — so it is intentionally absent here.\n_OAUTH_ENV_CREDENTIALS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\"ANTHROPIC_OAUTH_TOKEN\",),\n AuthMethod.GOOGLE_OAUTH: (\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"),\n AuthMethod.COPILOT_OAUTH: (\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"),\n AuthMethod.GROK_OAUTH: (\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"),\n AuthMethod.PERPLEXITY_OAUTH: (\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Any of ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint),\n ``OLLAMA_CLOUD_MODEL``, or a cloud API key\n (``OLLAMA_CLOUD_API_KEY`` / ``OLLAMA_API_KEY``) is enough to opt in.\n The key is included so a user who pasted only their key into\n `decepticon onboard` (relying on the default base + model) is still\n detected as a cloud user instead of silently falling through.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_API_KEY\", \"\").strip()\n or os.getenv(\"OLLAMA_API_KEY\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _oauth_env_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if any env var the handler accepts as a credential is set.\n\n Env-only OAuth setups (e.g. ``GEMINI_ACCESS_TOKEN`` with no token file)\n are valid: the LiteLLM handler authenticates straight from the env. Such\n a method must not be dropped from the fallback chain just because no file\n exists on disk.\n \"\"\"\n return any(os.getenv(env_var, \"\").strip() for env_var in _OAUTH_ENV_CREDENTIALS.get(method, ()))\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need the boolean intent AND a usable credential. The\n # credential may live in a token file OR in the handler's env-var\n # override — env-only setups are valid and must not be dropped.\n if not _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")):\n return False\n return _oauth_credentials_present(method) or _oauth_env_credentials_present(method)\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.OPENCODE_API: \"OpenCode Zen — API key\",\n AuthMethod.VERCEL_GATEWAY_API: \"Vercel AI Gateway — API key\",\n AuthMethod.HUGGINGFACE_API: \"Hugging Face Router — token\",\n AuthMethod.VENICE_API: \"Venice AI — API key\",\n AuthMethod.NANOGPT_API: \"NanoGPT — API key\",\n AuthMethod.SYNTHETIC_API: \"Synthetic — API key\",\n AuthMethod.ZENMUX_API: \"ZenMux — API key\",\n AuthMethod.QIANFAN_API: \"Baidu Qianfan (ERNIE) — API key\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"Cloudflare AI Gateway — API key + base URL\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n if _oauth_env_credentials_present(method):\n return f\"{flag}=true and credential present (env)\"\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n env_vars = \", \".join(_OAUTH_ENV_CREDENTIALS.get(method, ()))\n sources = paths or \"token\"\n if env_vars:\n sources = f\"{sources} or {env_vars}\"\n return f\"{flag}=true but no credential ({sources})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n result = super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n _log_served_model(self.model_name, result)\n return result\n\n async def ainvoke(self, *args, **kwargs):\n try:\n result = await call_with_timeout(\n super().ainvoke(*args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n _log_served_model(self.model_name, result)\n return result\n\n\ndef _log_served_model(requested: str, result: object) -> None:\n \"\"\"Best-effort attribution log: which provider/model the LiteLLM proxy\n actually routed to. When fallback fires (primary -> fallback) the\n requested model id and the served model id diverge — surfacing the\n delta lets operators trace cost/perf/debug back to the real upstream.\n\n Never raise from this path: attribution is observability, not control\n flow. Any extraction failure is swallowed silently.\n \"\"\"\n try:\n metadata = getattr(result, \"response_metadata\", None) or {}\n served = (\n metadata.get(\"model_name\")\n or metadata.get(\"model\")\n or getattr(result, \"model_name\", None)\n )\n if served and served != requested:\n log.info(\"llm.attribution requested=%s served=%s\", requested, served)\n elif served:\n log.debug(\"llm.attribution requested=%s served=%s\", requested, served)\n except Exception:\n pass\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\n# Patterns matching secret-shaped substrings that providers may echo back\n# inside HTTP error bodies. ``str(exc)`` for LiteLLM/openai errors is built\n# from that body, so it can reflect the Authorization/x-api-key/Bearer\n# credential we sent. Each pattern below scrubs one shape before the text is\n# interpolated into a user-facing RuntimeError that lands in CLI output/logs.\n_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (\n # \"Bearer <token>\" auth scheme (RFC 6750), case-insensitive.\n (re.compile(r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"), \"Bearer [REDACTED]\"),\n # Provider key prefixes: Anthropic sk-ant-* and OpenAI-style sk-* (any\n # remaining long token after the prefix).\n (re.compile(r\"\\bsk-(?:ant-)?[\\w.\\-]{8,}\"), \"[REDACTED]\"),\n # Header/field values for authorization / api_key / x-api-key, whether\n # JSON (\"authorization\": \"...\") or kwarg (api_key=...). Keeps the key\n # name so the guidance stays readable; only the value is scrubbed.\n (\n re.compile(\n r\"(?i)(['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*)\"\n r\"(['\\\"]?)[^'\\\"\\s,}]+\\2\"\n ),\n r\"\\1\\2[REDACTED]\\2\",\n ),\n # Generic long opaque tokens (>=24 chars of key-ish alphabet) that survive\n # the targeted passes above — e.g. provider keys without an sk- prefix.\n (re.compile(r\"\\b[A-Za-z0-9_\\-]{24,}\\b\"), \"[REDACTED]\"),\n)\n\n\ndef _redact_secrets(text: str) -> str:\n \"\"\"Scrub credential-shaped substrings from upstream error text.\n\n LiteLLM/openai surface the provider's raw HTTP response body via\n ``str(exc)``; that body can echo the ``Authorization``/``x-api-key``/\n ``Bearer`` value we sent. Redacting here keeps the actionable guidance\n (status code, model id, human hint) intact while preventing the secret\n from leaking into CLI output and logs.\n \"\"\"\n for pattern, replacement in _SECRET_PATTERNS:\n text = pattern.sub(replacement, text)\n return text\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {_redact_secrets(str(exc))}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n # Match on the raw text (status codes / keywords are not secret-shaped),\n # but interpolate the scrubbed copy so an echoed credential never reaches\n # the user-facing message — see _redact_secrets.\n safe_msg = _redact_secrets(msg)\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {safe_msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n resolved_profile = profile if profile is not None else self._resolve_profile()\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n self._proxy.url,\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "4a31bb57feca58e32d9fac1e8cccf44c1ab2a4e9": {"t": 1785755450.3186684, "n": 62416, "h": "09b8eff4e5c1dbef", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nimport re\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AGENT_TIERS,\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n Tier,\n resolve_chain,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env > default ``600``.\n Rejects non-positive or non-numeric env values with ``ValueError`` so\n misconfiguration fails loudly rather than silently disabling the guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n if raw:\n value = float(raw)\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{LLM_TIMEOUT_ENV} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Placed\n # after the first-party API providers and before the local endpoints:\n # a hosted gateway is a reasonable fallback but shouldn't preempt a\n # direct vendor key the user also configured.\n AuthMethod.OPENCODE_API,\n AuthMethod.VERCEL_GATEWAY_API,\n AuthMethod.ZENMUX_API,\n AuthMethod.NANOGPT_API,\n AuthMethod.VENICE_API,\n AuthMethod.SYNTHETIC_API,\n AuthMethod.HUGGINGFACE_API,\n AuthMethod.QIANFAN_API,\n AuthMethod.CLOUDFLARE_GATEWAY_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Each is\n # detected by the presence of a non-placeholder bearer key. The base\n # URL is fixed per gateway (see config/litellm.yaml) except Cloudflare,\n # whose per-account CLOUDFLARE_AI_GATEWAY_API_BASE must also be set —\n # documented in .env.example; a key without the base 404s at call time.\n AuthMethod.OPENCODE_API: \"OPENCODE_API_KEY\",\n AuthMethod.VERCEL_GATEWAY_API: \"VERCEL_AI_GATEWAY_API_KEY\",\n AuthMethod.HUGGINGFACE_API: \"HF_TOKEN\",\n AuthMethod.VENICE_API: \"VENICE_API_KEY\",\n AuthMethod.NANOGPT_API: \"NANOGPT_API_KEY\",\n AuthMethod.SYNTHETIC_API: \"SYNTHETIC_API_KEY\",\n AuthMethod.ZENMUX_API: \"ZENMUX_API_KEY\",\n AuthMethod.QIANFAN_API: \"QIANFAN_API_KEY\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"CLOUDFLARE_AI_GATEWAY_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n# Env vars each LiteLLM OAuth handler accepts as a credential *in place of*\n# its on-disk token file (see config/*_handler.py ``_load_tokens`` /\n# ``_env_override_tokens`` / ``_resolve_source_token``). A non-empty value in\n# any of these means the handler can authenticate without a file, so the\n# method must still count as configured. Codex (OPENAI_OAUTH) exposes no\n# env-token override — only the file-path env vars already covered by\n# ``_OAUTH_CREDENTIAL_PATHS`` — so it is intentionally absent here.\n_OAUTH_ENV_CREDENTIALS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\"ANTHROPIC_OAUTH_TOKEN\",),\n AuthMethod.GOOGLE_OAUTH: (\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"),\n AuthMethod.COPILOT_OAUTH: (\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"),\n AuthMethod.GROK_OAUTH: (\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"),\n AuthMethod.PERPLEXITY_OAUTH: (\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Any of ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint),\n ``OLLAMA_CLOUD_MODEL``, or a cloud API key\n (``OLLAMA_CLOUD_API_KEY`` / ``OLLAMA_API_KEY``) is enough to opt in.\n The key is included so a user who pasted only their key into\n `decepticon onboard` (relying on the default base + model) is still\n detected as a cloud user instead of silently falling through.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_API_KEY\", \"\").strip()\n or os.getenv(\"OLLAMA_API_KEY\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _oauth_env_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if any env var the handler accepts as a credential is set.\n\n Env-only OAuth setups (e.g. ``GEMINI_ACCESS_TOKEN`` with no token file)\n are valid: the LiteLLM handler authenticates straight from the env. Such\n a method must not be dropped from the fallback chain just because no file\n exists on disk.\n \"\"\"\n return any(os.getenv(env_var, \"\").strip() for env_var in _OAUTH_ENV_CREDENTIALS.get(method, ()))\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need the boolean intent AND a usable credential. The\n # credential may live in a token file OR in the handler's env-var\n # override — env-only setups are valid and must not be dropped.\n if not _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")):\n return False\n return _oauth_credentials_present(method) or _oauth_env_credentials_present(method)\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.OPENCODE_API: \"OpenCode Zen — API key\",\n AuthMethod.VERCEL_GATEWAY_API: \"Vercel AI Gateway — API key\",\n AuthMethod.HUGGINGFACE_API: \"Hugging Face Router — token\",\n AuthMethod.VENICE_API: \"Venice AI — API key\",\n AuthMethod.NANOGPT_API: \"NanoGPT — API key\",\n AuthMethod.SYNTHETIC_API: \"Synthetic — API key\",\n AuthMethod.ZENMUX_API: \"ZenMux — API key\",\n AuthMethod.QIANFAN_API: \"Baidu Qianfan (ERNIE) — API key\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"Cloudflare AI Gateway — API key + base URL\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n if _oauth_env_credentials_present(method):\n return f\"{flag}=true and credential present (env)\"\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n env_vars = \", \".join(_OAUTH_ENV_CREDENTIALS.get(method, ()))\n sources = paths or \"token\"\n if env_vars:\n sources = f\"{sources} or {env_vars}\"\n return f\"{flag}=true but no credential ({sources})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n return super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n async def ainvoke(self, *args, **kwargs):\n try:\n return await call_with_timeout(\n super().ainvoke(*args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\n# Patterns matching secret-shaped substrings that providers may echo back\n# inside HTTP error bodies. ``str(exc)`` for LiteLLM/openai errors is built\n# from that body, so it can reflect the Authorization/x-api-key/Bearer\n# credential we sent. Each pattern below scrubs one shape before the text is\n# interpolated into a user-facing RuntimeError that lands in CLI output/logs.\n_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (\n # \"Bearer <token>\" auth scheme (RFC 6750), case-insensitive.\n (re.compile(r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"), \"Bearer [REDACTED]\"),\n # Provider key prefixes: Anthropic sk-ant-* and OpenAI-style sk-* (any\n # remaining long token after the prefix).\n (re.compile(r\"\\bsk-(?:ant-)?[\\w.\\-]{8,}\"), \"[REDACTED]\"),\n # Header/field values for authorization / api_key / x-api-key, whether\n # JSON (\"authorization\": \"...\") or kwarg (api_key=...). Keeps the key\n # name so the guidance stays readable; only the value is scrubbed.\n (\n re.compile(\n r\"(?i)(['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*)\"\n r\"(['\\\"]?)[^'\\\"\\s,}]+\\2\"\n ),\n r\"\\1\\2[REDACTED]\\2\",\n ),\n # Generic long opaque tokens (>=24 chars of key-ish alphabet) that survive\n # the targeted passes above — e.g. provider keys without an sk- prefix.\n (re.compile(r\"\\b[A-Za-z0-9_\\-]{24,}\\b\"), \"[REDACTED]\"),\n)\n\n\ndef _redact_secrets(text: str) -> str:\n \"\"\"Scrub credential-shaped substrings from upstream error text.\n\n LiteLLM/openai surface the provider's raw HTTP response body via\n ``str(exc)``; that body can echo the ``Authorization``/``x-api-key``/\n ``Bearer`` value we sent. Redacting here keeps the actionable guidance\n (status code, model id, human hint) intact while preventing the secret\n from leaking into CLI output and logs.\n \"\"\"\n for pattern, replacement in _SECRET_PATTERNS:\n text = pattern.sub(replacement, text)\n return text\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {_redact_secrets(str(exc))}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n # Match on the raw text (status codes / keywords are not secret-shaped),\n # but interpolate the scrubbed copy so an echoed credential never reaches\n # the user-facing message — see _redact_secrets.\n safe_msg = _redact_secrets(msg)\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {safe_msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n\ndef _tiers_required(profile: ModelProfile) -> dict[Tier, list[str]]:\n if profile == ModelProfile.MAX:\n return {Tier.HIGH: list(AGENT_TIERS)}\n if profile == ModelProfile.TEST:\n return {Tier.LOW: list(AGENT_TIERS)}\n grouped: dict[Tier, list[str]] = {}\n for role, tier in AGENT_TIERS.items():\n grouped.setdefault(tier, []).append(role)\n return grouped\n\n\ndef _validate_chain_coverage(credentials: Credentials, profile: ModelProfile) -> None:\n \"\"\"Fail fast when ``credentials`` resolve to an EMPTY chain for any\n tier the factory must serve under ``profile``.\n\n ``LLMModelMapping.from_credentials_and_profile`` silently skips a role\n whose resolved chain is empty (no configured AuthMethod has a\n ``(method, tier)`` entry in ``METHOD_MODELS``). Downstream this surfaces\n as a confusing ``KeyError`` at ``get_model`` time or a primary-less\n ``ModelFallbackMiddleware([])``. Surface it once, here, with the\n offending tier(s) + the configured inventory so the operator can fix\n config immediately rather than chase a runtime failure.\n \"\"\"\n empty: list[tuple[Tier, list[str]]] = [\n (tier, roles)\n for tier, roles in _tiers_required(profile).items()\n if not resolve_chain(tier, credentials)\n ]\n if not empty:\n return\n details = \"; \".join(\n f\"{tier.value} (roles: {', '.join(sorted(roles))})\" for tier, roles in empty\n )\n inventory = \", \".join(m.value for m in credentials.methods) or \"<none>\"\n tier_list = \", \".join(tier.value for tier, _ in empty)\n raise ValueError(\n f\"LLMFactory: no model available for tier(s) [{tier_list}] under \"\n f\"profile {profile.value!r}. Empty chains: {details}. Configured \"\n f\"credentials: [{inventory}]. Add a credential whose AuthMethod \"\n f\"has an entry for the listed tier(s) in METHOD_MODELS \"\n f\"(packages/decepticon-core/decepticon_core/types/llm.py), or \"\n f\"adjust DECEPTICON_AUTH_PRIORITY / DECEPTICON_MODEL_PROFILE.\"\n )\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n raw_profile = profile if profile is not None else self._resolve_profile()\n resolved_profile = ModelProfile(raw_profile)\n _validate_chain_coverage(creds, resolved_profile)\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n self._proxy.url,\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "ca6788ef368a80026908e443bd8b3f8b6ad6be1b": {"t": 1785755454.242125, "n": 62383, "h": "4653738200dc2150", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nimport re\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any, Literal\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env > default ``600``.\n Rejects non-positive or non-numeric env values with ``ValueError`` so\n misconfiguration fails loudly rather than silently disabling the guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n if raw:\n value = float(raw)\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{LLM_TIMEOUT_ENV} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Placed\n # after the first-party API providers and before the local endpoints:\n # a hosted gateway is a reasonable fallback but shouldn't preempt a\n # direct vendor key the user also configured.\n AuthMethod.OPENCODE_API,\n AuthMethod.VERCEL_GATEWAY_API,\n AuthMethod.ZENMUX_API,\n AuthMethod.NANOGPT_API,\n AuthMethod.VENICE_API,\n AuthMethod.SYNTHETIC_API,\n AuthMethod.HUGGINGFACE_API,\n AuthMethod.QIANFAN_API,\n AuthMethod.CLOUDFLARE_GATEWAY_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Each is\n # detected by the presence of a non-placeholder bearer key. The base\n # URL is fixed per gateway (see config/litellm.yaml) except Cloudflare,\n # whose per-account CLOUDFLARE_AI_GATEWAY_API_BASE must also be set —\n # documented in .env.example; a key without the base 404s at call time.\n AuthMethod.OPENCODE_API: \"OPENCODE_API_KEY\",\n AuthMethod.VERCEL_GATEWAY_API: \"VERCEL_AI_GATEWAY_API_KEY\",\n AuthMethod.HUGGINGFACE_API: \"HF_TOKEN\",\n AuthMethod.VENICE_API: \"VENICE_API_KEY\",\n AuthMethod.NANOGPT_API: \"NANOGPT_API_KEY\",\n AuthMethod.SYNTHETIC_API: \"SYNTHETIC_API_KEY\",\n AuthMethod.ZENMUX_API: \"ZENMUX_API_KEY\",\n AuthMethod.QIANFAN_API: \"QIANFAN_API_KEY\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"CLOUDFLARE_AI_GATEWAY_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n# Env vars each LiteLLM OAuth handler accepts as a credential *in place of*\n# its on-disk token file (see config/*_handler.py ``_load_tokens`` /\n# ``_env_override_tokens`` / ``_resolve_source_token``). A non-empty value in\n# any of these means the handler can authenticate without a file, so the\n# method must still count as configured. Codex (OPENAI_OAUTH) exposes no\n# env-token override — only the file-path env vars already covered by\n# ``_OAUTH_CREDENTIAL_PATHS`` — so it is intentionally absent here.\n_OAUTH_ENV_CREDENTIALS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\"ANTHROPIC_OAUTH_TOKEN\",),\n AuthMethod.GOOGLE_OAUTH: (\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"),\n AuthMethod.COPILOT_OAUTH: (\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"),\n AuthMethod.GROK_OAUTH: (\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"),\n AuthMethod.PERPLEXITY_OAUTH: (\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Any of ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint),\n ``OLLAMA_CLOUD_MODEL``, or a cloud API key\n (``OLLAMA_CLOUD_API_KEY`` / ``OLLAMA_API_KEY``) is enough to opt in.\n The key is included so a user who pasted only their key into\n `decepticon onboard` (relying on the default base + model) is still\n detected as a cloud user instead of silently falling through.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_API_KEY\", \"\").strip()\n or os.getenv(\"OLLAMA_API_KEY\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _oauth_env_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if any env var the handler accepts as a credential is set.\n\n Env-only OAuth setups (e.g. ``GEMINI_ACCESS_TOKEN`` with no token file)\n are valid: the LiteLLM handler authenticates straight from the env. Such\n a method must not be dropped from the fallback chain just because no file\n exists on disk.\n \"\"\"\n return any(os.getenv(env_var, \"\").strip() for env_var in _OAUTH_ENV_CREDENTIALS.get(method, ()))\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need the boolean intent AND a usable credential. The\n # credential may live in a token file OR in the handler's env-var\n # override — env-only setups are valid and must not be dropped.\n if not _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")):\n return False\n return _oauth_credentials_present(method) or _oauth_env_credentials_present(method)\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.OPENCODE_API: \"OpenCode Zen — API key\",\n AuthMethod.VERCEL_GATEWAY_API: \"Vercel AI Gateway — API key\",\n AuthMethod.HUGGINGFACE_API: \"Hugging Face Router — token\",\n AuthMethod.VENICE_API: \"Venice AI — API key\",\n AuthMethod.NANOGPT_API: \"NanoGPT — API key\",\n AuthMethod.SYNTHETIC_API: \"Synthetic — API key\",\n AuthMethod.ZENMUX_API: \"ZenMux — API key\",\n AuthMethod.QIANFAN_API: \"Baidu Qianfan (ERNIE) — API key\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"Cloudflare AI Gateway — API key + base URL\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n if _oauth_env_credentials_present(method):\n return f\"{flag}=true and credential present (env)\"\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n env_vars = \", \".join(_OAUTH_ENV_CREDENTIALS.get(method, ()))\n sources = paths or \"token\"\n if env_vars:\n sources = f\"{sources} or {env_vars}\"\n return f\"{flag}=true but no credential ({sources})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n return super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n async def ainvoke(self, *args, **kwargs):\n try:\n return await call_with_timeout(\n super().ainvoke(*args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\n# Patterns matching secret-shaped substrings that providers may echo back\n# inside HTTP error bodies. ``str(exc)`` for LiteLLM/openai errors is built\n# from that body, so it can reflect the Authorization/x-api-key/Bearer\n# credential we sent. Each pattern below scrubs one shape before the text is\n# interpolated into a user-facing RuntimeError that lands in CLI output/logs.\n_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (\n # \"Bearer <token>\" auth scheme (RFC 6750), case-insensitive.\n (re.compile(r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"), \"Bearer [REDACTED]\"),\n # Provider key prefixes: Anthropic sk-ant-* and OpenAI-style sk-* (any\n # remaining long token after the prefix).\n (re.compile(r\"\\bsk-(?:ant-)?[\\w.\\-]{8,}\"), \"[REDACTED]\"),\n # Header/field values for authorization / api_key / x-api-key, whether\n # JSON (\"authorization\": \"...\") or kwarg (api_key=...). Keeps the key\n # name so the guidance stays readable; only the value is scrubbed.\n (\n re.compile(\n r\"(?i)(['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*)\"\n r\"(['\\\"]?)[^'\\\"\\s,}]+\\2\"\n ),\n r\"\\1\\2[REDACTED]\\2\",\n ),\n # Generic long opaque tokens (>=24 chars of key-ish alphabet) that survive\n # the targeted passes above — e.g. provider keys without an sk- prefix.\n (re.compile(r\"\\b[A-Za-z0-9_\\-]{24,}\\b\"), \"[REDACTED]\"),\n)\n\n\ndef _redact_secrets(text: str) -> str:\n \"\"\"Scrub credential-shaped substrings from upstream error text.\n\n LiteLLM/openai surface the provider's raw HTTP response body via\n ``str(exc)``; that body can echo the ``Authorization``/``x-api-key``/\n ``Bearer`` value we sent. Redacting here keeps the actionable guidance\n (status code, model id, human hint) intact while preventing the secret\n from leaking into CLI output and logs.\n \"\"\"\n for pattern, replacement in _SECRET_PATTERNS:\n text = pattern.sub(replacement, text)\n return text\n\n\nProviderErrorClass = Literal[\"retryable\", \"fatal\"]\n\n# HTTP status → class. 408/425/429 + 5xx are worth a retry / fallback hop;\n# 4xx auth/config (400/401/403/404) won't be fixed by another attempt.\n_RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})\n_FATAL_STATUS = frozenset({400, 401, 403, 404})\n\n# Last-resort message scrape when the exception lacks ``status_code`` —\n# LiteLLM/openai surface the upstream status only in the body text.\n_STATUS_IN_MSG = re.compile(r\"(?:error\\s*code|status(?:_code)?|http)\\D{0,8}(\\d{3})\", re.I)\n\n\ndef _classify_provider_error(exc: BaseException) -> ProviderErrorClass:\n \"\"\"Tag a provider exception as ``retryable`` or ``fatal``.\n\n Inspection order (cheapest signal first):\n 1. Our own ``LLMTimeoutError`` and httpx transport errors → retryable.\n 2. ``status_code`` attribute (openai.APIStatusError shape).\n 3. ``response.status_code`` (httpx.HTTPStatusError shape).\n 4. Regex over ``str(exc)`` — LiteLLM nests upstream status in the body.\n\n Default for unrecognised shapes is ``retryable`` so we preserve today's\n behavior (LiteLLM ``num_retries`` keeps spinning). Only flip to ``fatal``\n on a positive 4xx signal.\n \"\"\"\n if isinstance(exc, LLMTimeoutError):\n return \"retryable\"\n if isinstance(exc, (httpx.TransportError, httpx.TimeoutException)):\n return \"retryable\"\n\n status = getattr(exc, \"status_code\", None)\n if status is None:\n response = getattr(exc, \"response\", None)\n status = getattr(response, \"status_code\", None)\n if isinstance(status, int):\n if status in _FATAL_STATUS:\n return \"fatal\"\n if status in _RETRYABLE_STATUS:\n return \"retryable\"\n\n match = _STATUS_IN_MSG.search(str(exc))\n if match is not None:\n code = int(match.group(1))\n if code in _FATAL_STATUS:\n return \"fatal\"\n if code in _RETRYABLE_STATUS:\n return \"retryable\"\n\n return \"retryable\"\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {_redact_secrets(str(exc))}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n fatal_prefix = (\n \"non-retryable provider error: \" if _classify_provider_error(exc) == \"fatal\" else \"\"\n )\n # Match on the raw text (status codes / keywords are not secret-shaped),\n # but interpolate the scrubbed copy so an echoed credential never reaches\n # the user-facing message — see _redact_secrets.\n safe_msg = _redact_secrets(msg)\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {safe_msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"{fatal_prefix}Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n resolved_profile = profile if profile is not None else self._resolve_profile()\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n self._proxy.url,\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "0cdcbb8a6228432404ec378f6c4b3044fbc6e52c": {"t": 1785755456.258117, "n": 60917, "h": "b9f01e5207fde334", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nimport re\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env > default ``600``.\n Rejects non-positive or non-numeric env values with ``ValueError`` so\n misconfiguration fails loudly rather than silently disabling the guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n if raw:\n value = float(raw)\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{LLM_TIMEOUT_ENV} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Placed\n # after the first-party API providers and before the local endpoints:\n # a hosted gateway is a reasonable fallback but shouldn't preempt a\n # direct vendor key the user also configured.\n AuthMethod.OPENCODE_API,\n AuthMethod.VERCEL_GATEWAY_API,\n AuthMethod.ZENMUX_API,\n AuthMethod.NANOGPT_API,\n AuthMethod.VENICE_API,\n AuthMethod.SYNTHETIC_API,\n AuthMethod.HUGGINGFACE_API,\n AuthMethod.QIANFAN_API,\n AuthMethod.CLOUDFLARE_GATEWAY_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Each is\n # detected by the presence of a non-placeholder bearer key. The base\n # URL is fixed per gateway (see config/litellm.yaml) except Cloudflare,\n # whose per-account CLOUDFLARE_AI_GATEWAY_API_BASE must also be set —\n # documented in .env.example; a key without the base 404s at call time.\n AuthMethod.OPENCODE_API: \"OPENCODE_API_KEY\",\n AuthMethod.VERCEL_GATEWAY_API: \"VERCEL_AI_GATEWAY_API_KEY\",\n AuthMethod.HUGGINGFACE_API: \"HF_TOKEN\",\n AuthMethod.VENICE_API: \"VENICE_API_KEY\",\n AuthMethod.NANOGPT_API: \"NANOGPT_API_KEY\",\n AuthMethod.SYNTHETIC_API: \"SYNTHETIC_API_KEY\",\n AuthMethod.ZENMUX_API: \"ZENMUX_API_KEY\",\n AuthMethod.QIANFAN_API: \"QIANFAN_API_KEY\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"CLOUDFLARE_AI_GATEWAY_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n# Env vars each LiteLLM OAuth handler accepts as a credential *in place of*\n# its on-disk token file (see config/*_handler.py ``_load_tokens`` /\n# ``_env_override_tokens`` / ``_resolve_source_token``). A non-empty value in\n# any of these means the handler can authenticate without a file, so the\n# method must still count as configured. Codex (OPENAI_OAUTH) exposes no\n# env-token override — only the file-path env vars already covered by\n# ``_OAUTH_CREDENTIAL_PATHS`` — so it is intentionally absent here.\n_OAUTH_ENV_CREDENTIALS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\"ANTHROPIC_OAUTH_TOKEN\",),\n AuthMethod.GOOGLE_OAUTH: (\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"),\n AuthMethod.COPILOT_OAUTH: (\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"),\n AuthMethod.GROK_OAUTH: (\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"),\n AuthMethod.PERPLEXITY_OAUTH: (\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Any of ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint),\n ``OLLAMA_CLOUD_MODEL``, or a cloud API key\n (``OLLAMA_CLOUD_API_KEY`` / ``OLLAMA_API_KEY``) is enough to opt in.\n The key is included so a user who pasted only their key into\n `decepticon onboard` (relying on the default base + model) is still\n detected as a cloud user instead of silently falling through.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_API_KEY\", \"\").strip()\n or os.getenv(\"OLLAMA_API_KEY\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _oauth_env_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if any env var the handler accepts as a credential is set.\n\n Env-only OAuth setups (e.g. ``GEMINI_ACCESS_TOKEN`` with no token file)\n are valid: the LiteLLM handler authenticates straight from the env. Such\n a method must not be dropped from the fallback chain just because no file\n exists on disk.\n \"\"\"\n return any(os.getenv(env_var, \"\").strip() for env_var in _OAUTH_ENV_CREDENTIALS.get(method, ()))\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need the boolean intent AND a usable credential. The\n # credential may live in a token file OR in the handler's env-var\n # override — env-only setups are valid and must not be dropped.\n if not _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")):\n return False\n return _oauth_credentials_present(method) or _oauth_env_credentials_present(method)\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.OPENCODE_API: \"OpenCode Zen — API key\",\n AuthMethod.VERCEL_GATEWAY_API: \"Vercel AI Gateway — API key\",\n AuthMethod.HUGGINGFACE_API: \"Hugging Face Router — token\",\n AuthMethod.VENICE_API: \"Venice AI — API key\",\n AuthMethod.NANOGPT_API: \"NanoGPT — API key\",\n AuthMethod.SYNTHETIC_API: \"Synthetic — API key\",\n AuthMethod.ZENMUX_API: \"ZenMux — API key\",\n AuthMethod.QIANFAN_API: \"Baidu Qianfan (ERNIE) — API key\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"Cloudflare AI Gateway — API key + base URL\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n if _oauth_env_credentials_present(method):\n return f\"{flag}=true and credential present (env)\"\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n env_vars = \", \".join(_OAUTH_ENV_CREDENTIALS.get(method, ()))\n sources = paths or \"token\"\n if env_vars:\n sources = f\"{sources} or {env_vars}\"\n return f\"{flag}=true but no credential ({sources})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n return super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n async def ainvoke(self, *args, **kwargs):\n try:\n return await call_with_timeout(\n super().ainvoke(*args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\n# Patterns matching secret-shaped substrings that providers may echo back\n# inside HTTP error bodies. ``str(exc)`` for LiteLLM/openai errors is built\n# from that body, so it can reflect the Authorization/x-api-key/Bearer\n# credential we sent. Each pattern below scrubs one shape before the text is\n# interpolated into a user-facing RuntimeError that lands in CLI output/logs.\n_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (\n # \"Bearer <token>\" auth scheme (RFC 6750), case-insensitive.\n (re.compile(r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"), \"Bearer [REDACTED]\"),\n # Provider key prefixes: Anthropic sk-ant-* and OpenAI-style sk-* (any\n # remaining long token after the prefix).\n (re.compile(r\"\\bsk-(?:ant-)?[\\w.\\-]{8,}\"), \"[REDACTED]\"),\n # Header/field values for authorization / api_key / x-api-key, whether\n # JSON (\"authorization\": \"...\") or kwarg (api_key=...). Keeps the key\n # name so the guidance stays readable; only the value is scrubbed.\n (\n re.compile(\n r\"(?i)(['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*)\"\n r\"(['\\\"]?)[^'\\\"\\s,}]+\\2\"\n ),\n r\"\\1\\2[REDACTED]\\2\",\n ),\n # Generic long opaque tokens (>=24 chars of key-ish alphabet) that survive\n # the targeted passes above — e.g. provider keys without an sk- prefix.\n (re.compile(r\"\\b[A-Za-z0-9_\\-]{24,}\\b\"), \"[REDACTED]\"),\n)\n\n\ndef _redact_url_credentials(url: str) -> str:\n \"\"\"Strip ``user:pass@`` userinfo from a URL before logging.\n\n Operators may configure the proxy URL with embedded credentials\n (``http://user:pass@litellm:4000``); those must not reach INFO logs.\n \"\"\"\n from urllib.parse import urlsplit, urlunsplit\n\n try:\n parts = urlsplit(url)\n except ValueError:\n return url\n if not parts.scheme or \"@\" not in parts.netloc:\n return url\n host = parts.hostname or \"\"\n if parts.port is not None:\n host = f\"{host}:{parts.port}\"\n return urlunsplit((parts.scheme, f\"***@{host}\", parts.path, parts.query, parts.fragment))\n\n\ndef _redact_secrets(text: str) -> str:\n \"\"\"Scrub credential-shaped substrings from upstream error text.\n\n LiteLLM/openai surface the provider's raw HTTP response body via\n ``str(exc)``; that body can echo the ``Authorization``/``x-api-key``/\n ``Bearer`` value we sent. Redacting here keeps the actionable guidance\n (status code, model id, human hint) intact while preventing the secret\n from leaking into CLI output and logs.\n \"\"\"\n for pattern, replacement in _SECRET_PATTERNS:\n text = pattern.sub(replacement, text)\n return text\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {_redact_secrets(str(exc))}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n # Match on the raw text (status codes / keywords are not secret-shaped),\n # but interpolate the scrubbed copy so an echoed credential never reaches\n # the user-facing message — see _redact_secrets.\n safe_msg = _redact_secrets(msg)\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {safe_msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n resolved_profile = profile if profile is not None else self._resolve_profile()\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n _redact_url_credentials(self._proxy.url),\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "2b17ec2f2a3bba7a990d1211e54a2b937fab0d2e": {"t": 1785755459.9743218, "n": 60232, "h": "8783c93608d6439a", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nimport re\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env > default ``600``.\n Rejects non-positive or non-numeric env values with ``ValueError`` so\n misconfiguration fails loudly rather than silently disabling the guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n if raw:\n value = float(raw)\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{LLM_TIMEOUT_ENV} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Placed\n # after the first-party API providers and before the local endpoints:\n # a hosted gateway is a reasonable fallback but shouldn't preempt a\n # direct vendor key the user also configured.\n AuthMethod.OPENCODE_API,\n AuthMethod.VERCEL_GATEWAY_API,\n AuthMethod.ZENMUX_API,\n AuthMethod.NANOGPT_API,\n AuthMethod.VENICE_API,\n AuthMethod.SYNTHETIC_API,\n AuthMethod.HUGGINGFACE_API,\n AuthMethod.QIANFAN_API,\n AuthMethod.CLOUDFLARE_GATEWAY_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Each is\n # detected by the presence of a non-placeholder bearer key. The base\n # URL is fixed per gateway (see config/litellm.yaml) except Cloudflare,\n # whose per-account CLOUDFLARE_AI_GATEWAY_API_BASE must also be set —\n # documented in .env.example; a key without the base 404s at call time.\n AuthMethod.OPENCODE_API: \"OPENCODE_API_KEY\",\n AuthMethod.VERCEL_GATEWAY_API: \"VERCEL_AI_GATEWAY_API_KEY\",\n AuthMethod.HUGGINGFACE_API: \"HF_TOKEN\",\n AuthMethod.VENICE_API: \"VENICE_API_KEY\",\n AuthMethod.NANOGPT_API: \"NANOGPT_API_KEY\",\n AuthMethod.SYNTHETIC_API: \"SYNTHETIC_API_KEY\",\n AuthMethod.ZENMUX_API: \"ZENMUX_API_KEY\",\n AuthMethod.QIANFAN_API: \"QIANFAN_API_KEY\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"CLOUDFLARE_AI_GATEWAY_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n# Env vars each LiteLLM OAuth handler accepts as a credential *in place of*\n# its on-disk token file (see config/*_handler.py ``_load_tokens`` /\n# ``_env_override_tokens`` / ``_resolve_source_token``). A non-empty value in\n# any of these means the handler can authenticate without a file, so the\n# method must still count as configured. Codex (OPENAI_OAUTH) exposes no\n# env-token override — only the file-path env vars already covered by\n# ``_OAUTH_CREDENTIAL_PATHS`` — so it is intentionally absent here.\n_OAUTH_ENV_CREDENTIALS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\"ANTHROPIC_OAUTH_TOKEN\",),\n AuthMethod.GOOGLE_OAUTH: (\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"),\n AuthMethod.COPILOT_OAUTH: (\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"),\n AuthMethod.GROK_OAUTH: (\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"),\n AuthMethod.PERPLEXITY_OAUTH: (\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Any of ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint),\n ``OLLAMA_CLOUD_MODEL``, or a cloud API key\n (``OLLAMA_CLOUD_API_KEY`` / ``OLLAMA_API_KEY``) is enough to opt in.\n The key is included so a user who pasted only their key into\n `decepticon onboard` (relying on the default base + model) is still\n detected as a cloud user instead of silently falling through.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_API_KEY\", \"\").strip()\n or os.getenv(\"OLLAMA_API_KEY\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _oauth_env_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if any env var the handler accepts as a credential is set.\n\n Env-only OAuth setups (e.g. ``GEMINI_ACCESS_TOKEN`` with no token file)\n are valid: the LiteLLM handler authenticates straight from the env. Such\n a method must not be dropped from the fallback chain just because no file\n exists on disk.\n \"\"\"\n return any(os.getenv(env_var, \"\").strip() for env_var in _OAUTH_ENV_CREDENTIALS.get(method, ()))\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need the boolean intent AND a usable credential. The\n # credential may live in a token file OR in the handler's env-var\n # override — env-only setups are valid and must not be dropped.\n if not _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")):\n return False\n return _oauth_credentials_present(method) or _oauth_env_credentials_present(method)\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.OPENCODE_API: \"OpenCode Zen — API key\",\n AuthMethod.VERCEL_GATEWAY_API: \"Vercel AI Gateway — API key\",\n AuthMethod.HUGGINGFACE_API: \"Hugging Face Router — token\",\n AuthMethod.VENICE_API: \"Venice AI — API key\",\n AuthMethod.NANOGPT_API: \"NanoGPT — API key\",\n AuthMethod.SYNTHETIC_API: \"Synthetic — API key\",\n AuthMethod.ZENMUX_API: \"ZenMux — API key\",\n AuthMethod.QIANFAN_API: \"Baidu Qianfan (ERNIE) — API key\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"Cloudflare AI Gateway — API key + base URL\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n if _oauth_env_credentials_present(method):\n return f\"{flag}=true and credential present (env)\"\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n env_vars = \", \".join(_OAUTH_ENV_CREDENTIALS.get(method, ()))\n sources = paths or \"token\"\n if env_vars:\n sources = f\"{sources} or {env_vars}\"\n return f\"{flag}=true but no credential ({sources})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n return super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n async def ainvoke(self, *args, **kwargs):\n try:\n return await call_with_timeout(\n super().ainvoke(*args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\n# Patterns matching secret-shaped substrings that providers may echo back\n# inside HTTP error bodies. ``str(exc)`` for LiteLLM/openai errors is built\n# from that body, so it can reflect the Authorization/x-api-key/Bearer\n# credential we sent. Each pattern below scrubs one shape before the text is\n# interpolated into a user-facing RuntimeError that lands in CLI output/logs.\n_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (\n # \"Bearer <token>\" auth scheme (RFC 6750), case-insensitive.\n (re.compile(r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"), \"Bearer [REDACTED]\"),\n # Provider key prefixes: Anthropic sk-ant-* and OpenAI-style sk-* (any\n # remaining long token after the prefix).\n (re.compile(r\"\\bsk-(?:ant-)?[\\w.\\-]{8,}\"), \"[REDACTED]\"),\n # Header/field values for authorization / api_key / x-api-key, whether\n # JSON (\"authorization\": \"...\") or kwarg (api_key=...). Keeps the key\n # name so the guidance stays readable; only the value is scrubbed.\n (\n re.compile(\n r\"(?i)(['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*)\"\n r\"(['\\\"]?)[^'\\\"\\s,}]+\\2\"\n ),\n r\"\\1\\2[REDACTED]\\2\",\n ),\n # Generic long opaque tokens (>=24 chars of key-ish alphabet) that survive\n # the targeted passes above — e.g. provider keys without an sk- prefix.\n (re.compile(r\"\\b[A-Za-z0-9_\\-]{24,}\\b\"), \"[REDACTED]\"),\n)\n\n\ndef _redact_secrets(text: str) -> str:\n \"\"\"Scrub credential-shaped substrings from upstream error text.\n\n LiteLLM/openai surface the provider's raw HTTP response body via\n ``str(exc)``; that body can echo the ``Authorization``/``x-api-key``/\n ``Bearer`` value we sent. Redacting here keeps the actionable guidance\n (status code, model id, human hint) intact while preventing the secret\n from leaking into CLI output and logs.\n \"\"\"\n for pattern, replacement in _SECRET_PATTERNS:\n text = pattern.sub(replacement, text)\n return text\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {_redact_secrets(str(exc))}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n # Match on the raw text (status codes / keywords are not secret-shaped),\n # but interpolate the scrubbed copy so an echoed credential never reaches\n # the user-facing message — see _redact_secrets.\n safe_msg = _redact_secrets(msg)\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {safe_msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n resolved_profile = profile if profile is not None else self._resolve_profile()\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n self._proxy.url,\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "2c0cd62383031601acd2ad8dbd1d8787ef4df639": {"t": 1785755461.803205, "n": 55714, "h": "65b4a35c5a95dfdf", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env > default ``600``.\n Rejects non-positive or non-numeric env values with ``ValueError`` so\n misconfiguration fails loudly rather than silently disabling the guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n if raw:\n value = float(raw)\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{LLM_TIMEOUT_ENV} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Placed\n # after the first-party API providers and before the local endpoints:\n # a hosted gateway is a reasonable fallback but shouldn't preempt a\n # direct vendor key the user also configured.\n AuthMethod.OPENCODE_API,\n AuthMethod.VERCEL_GATEWAY_API,\n AuthMethod.ZENMUX_API,\n AuthMethod.NANOGPT_API,\n AuthMethod.VENICE_API,\n AuthMethod.SYNTHETIC_API,\n AuthMethod.HUGGINGFACE_API,\n AuthMethod.QIANFAN_API,\n AuthMethod.CLOUDFLARE_GATEWAY_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n # OpenAI-compatible gateways / aggregators (oh-my-pi parity). Each is\n # detected by the presence of a non-placeholder bearer key. The base\n # URL is fixed per gateway (see config/litellm.yaml) except Cloudflare,\n # whose per-account CLOUDFLARE_AI_GATEWAY_API_BASE must also be set —\n # documented in .env.example; a key without the base 404s at call time.\n AuthMethod.OPENCODE_API: \"OPENCODE_API_KEY\",\n AuthMethod.VERCEL_GATEWAY_API: \"VERCEL_AI_GATEWAY_API_KEY\",\n AuthMethod.HUGGINGFACE_API: \"HF_TOKEN\",\n AuthMethod.VENICE_API: \"VENICE_API_KEY\",\n AuthMethod.NANOGPT_API: \"NANOGPT_API_KEY\",\n AuthMethod.SYNTHETIC_API: \"SYNTHETIC_API_KEY\",\n AuthMethod.ZENMUX_API: \"ZENMUX_API_KEY\",\n AuthMethod.QIANFAN_API: \"QIANFAN_API_KEY\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"CLOUDFLARE_AI_GATEWAY_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Either ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_CLOUD_MODEL`` is enough to opt in.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need BOTH the boolean intent and the actual\n # credential file (see ``_oauth_credentials_present`` for why).\n return _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")) and _oauth_credentials_present(\n method\n )\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.OPENCODE_API: \"OpenCode Zen — API key\",\n AuthMethod.VERCEL_GATEWAY_API: \"Vercel AI Gateway — API key\",\n AuthMethod.HUGGINGFACE_API: \"Hugging Face Router — token\",\n AuthMethod.VENICE_API: \"Venice AI — API key\",\n AuthMethod.NANOGPT_API: \"NanoGPT — API key\",\n AuthMethod.SYNTHETIC_API: \"Synthetic — API key\",\n AuthMethod.ZENMUX_API: \"ZenMux — API key\",\n AuthMethod.QIANFAN_API: \"Baidu Qianfan (ERNIE) — API key\",\n AuthMethod.CLOUDFLARE_GATEWAY_API: \"Cloudflare AI Gateway — API key + base URL\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n return f\"{flag}=true but no credential file ({paths or 'token'})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n return super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n async def ainvoke(self, *args, **kwargs):\n try:\n return await call_with_timeout(\n super().ainvoke(*args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {exc}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {msg}\"\n ) from exc\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n resolved_profile = profile if profile is not None else self._resolve_profile()\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n self._proxy.url,\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "aff958fe7891732deb6ed46c8fcfd9d0c06bc358": {"t": 1785755463.5834258, "n": 58320, "h": "b1307716b4ea7be2", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nimport re\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env > default ``600``.\n Rejects non-positive or non-numeric env values with ``ValueError`` so\n misconfiguration fails loudly rather than silently disabling the guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n if raw:\n value = float(raw)\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{LLM_TIMEOUT_ENV} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n# Env vars each LiteLLM OAuth handler accepts as a credential *in place of*\n# its on-disk token file (see config/*_handler.py ``_load_tokens`` /\n# ``_env_override_tokens`` / ``_resolve_source_token``). A non-empty value in\n# any of these means the handler can authenticate without a file, so the\n# method must still count as configured. Codex (OPENAI_OAUTH) exposes no\n# env-token override — only the file-path env vars already covered by\n# ``_OAUTH_CREDENTIAL_PATHS`` — so it is intentionally absent here.\n_OAUTH_ENV_CREDENTIALS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\"ANTHROPIC_OAUTH_TOKEN\",),\n AuthMethod.GOOGLE_OAUTH: (\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"),\n AuthMethod.COPILOT_OAUTH: (\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"),\n AuthMethod.GROK_OAUTH: (\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"),\n AuthMethod.PERPLEXITY_OAUTH: (\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Any of ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint),\n ``OLLAMA_CLOUD_MODEL``, or a cloud API key\n (``OLLAMA_CLOUD_API_KEY`` / ``OLLAMA_API_KEY``) is enough to opt in.\n The key is included so a user who pasted only their key into\n `decepticon onboard` (relying on the default base + model) is still\n detected as a cloud user instead of silently falling through.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_API_KEY\", \"\").strip()\n or os.getenv(\"OLLAMA_API_KEY\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _oauth_env_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if any env var the handler accepts as a credential is set.\n\n Env-only OAuth setups (e.g. ``GEMINI_ACCESS_TOKEN`` with no token file)\n are valid: the LiteLLM handler authenticates straight from the env. Such\n a method must not be dropped from the fallback chain just because no file\n exists on disk.\n \"\"\"\n return any(os.getenv(env_var, \"\").strip() for env_var in _OAUTH_ENV_CREDENTIALS.get(method, ()))\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need the boolean intent AND a usable credential. The\n # credential may live in a token file OR in the handler's env-var\n # override — env-only setups are valid and must not be dropped.\n if not _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")):\n return False\n return _oauth_credentials_present(method) or _oauth_env_credentials_present(method)\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n if _oauth_env_credentials_present(method):\n return f\"{flag}=true and credential present (env)\"\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n env_vars = \", \".join(_OAUTH_ENV_CREDENTIALS.get(method, ()))\n sources = paths or \"token\"\n if env_vars:\n sources = f\"{sources} or {env_vars}\"\n return f\"{flag}=true but no credential ({sources})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n return super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n async def ainvoke(self, *args, **kwargs):\n try:\n return await call_with_timeout(\n super().ainvoke(*args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\n# Patterns matching secret-shaped substrings that providers may echo back\n# inside HTTP error bodies. ``str(exc)`` for LiteLLM/openai errors is built\n# from that body, so it can reflect the Authorization/x-api-key/Bearer\n# credential we sent. Each pattern below scrubs one shape before the text is\n# interpolated into a user-facing RuntimeError that lands in CLI output/logs.\n_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (\n # \"Bearer <token>\" auth scheme (RFC 6750), case-insensitive.\n (re.compile(r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"), \"Bearer [REDACTED]\"),\n # Provider key prefixes: Anthropic sk-ant-* and OpenAI-style sk-* (any\n # remaining long token after the prefix).\n (re.compile(r\"\\bsk-(?:ant-)?[\\w.\\-]{8,}\"), \"[REDACTED]\"),\n # Header/field values for authorization / api_key / x-api-key, whether\n # JSON (\"authorization\": \"...\") or kwarg (api_key=...). Keeps the key\n # name so the guidance stays readable; only the value is scrubbed.\n (\n re.compile(\n r\"(?i)(['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*)\"\n r\"(['\\\"]?)[^'\\\"\\s,}]+\\2\"\n ),\n r\"\\1\\2[REDACTED]\\2\",\n ),\n # Generic long opaque tokens (>=24 chars of key-ish alphabet) that survive\n # the targeted passes above — e.g. provider keys without an sk- prefix.\n (re.compile(r\"\\b[A-Za-z0-9_\\-]{24,}\\b\"), \"[REDACTED]\"),\n)\n\n\ndef _redact_secrets(text: str) -> str:\n \"\"\"Scrub credential-shaped substrings from upstream error text.\n\n LiteLLM/openai surface the provider's raw HTTP response body via\n ``str(exc)``; that body can echo the ``Authorization``/``x-api-key``/\n ``Bearer`` value we sent. Redacting here keeps the actionable guidance\n (status code, model id, human hint) intact while preventing the secret\n from leaking into CLI output and logs.\n \"\"\"\n for pattern, replacement in _SECRET_PATTERNS:\n text = pattern.sub(replacement, text)\n return text\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {_redact_secrets(str(exc))}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n # Match on the raw text (status codes / keywords are not secret-shaped),\n # but interpolate the scrubbed copy so an echoed credential never reaches\n # the user-facing message — see _redact_secrets.\n safe_msg = _redact_secrets(msg)\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {safe_msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n resolved_profile = profile if profile is not None else self._resolve_profile()\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n self._proxy.url,\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "2b8c6ac380139d193829a6c1b303a07a946abfbf": {"t": 1785755466.7623072, "n": 57939, "h": "5fbc6cff10451def", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nimport re\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env > default ``600``.\n Rejects non-positive or non-numeric env values with ``ValueError`` so\n misconfiguration fails loudly rather than silently disabling the guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n if raw:\n value = float(raw)\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{LLM_TIMEOUT_ENV} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n# Env vars each LiteLLM OAuth handler accepts as a credential *in place of*\n# its on-disk token file (see config/*_handler.py ``_load_tokens`` /\n# ``_env_override_tokens`` / ``_resolve_source_token``). A non-empty value in\n# any of these means the handler can authenticate without a file, so the\n# method must still count as configured. Codex (OPENAI_OAUTH) exposes no\n# env-token override — only the file-path env vars already covered by\n# ``_OAUTH_CREDENTIAL_PATHS`` — so it is intentionally absent here.\n_OAUTH_ENV_CREDENTIALS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\"ANTHROPIC_OAUTH_TOKEN\",),\n AuthMethod.GOOGLE_OAUTH: (\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"),\n AuthMethod.COPILOT_OAUTH: (\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"),\n AuthMethod.GROK_OAUTH: (\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"),\n AuthMethod.PERPLEXITY_OAUTH: (\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Either ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_CLOUD_MODEL`` is enough to opt in.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _oauth_env_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if any env var the handler accepts as a credential is set.\n\n Env-only OAuth setups (e.g. ``GEMINI_ACCESS_TOKEN`` with no token file)\n are valid: the LiteLLM handler authenticates straight from the env. Such\n a method must not be dropped from the fallback chain just because no file\n exists on disk.\n \"\"\"\n return any(os.getenv(env_var, \"\").strip() for env_var in _OAUTH_ENV_CREDENTIALS.get(method, ()))\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need the boolean intent AND a usable credential. The\n # credential may live in a token file OR in the handler's env-var\n # override — env-only setups are valid and must not be dropped.\n if not _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")):\n return False\n return _oauth_credentials_present(method) or _oauth_env_credentials_present(method)\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n if _oauth_env_credentials_present(method):\n return f\"{flag}=true and credential present (env)\"\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n env_vars = \", \".join(_OAUTH_ENV_CREDENTIALS.get(method, ()))\n sources = paths or \"token\"\n if env_vars:\n sources = f\"{sources} or {env_vars}\"\n return f\"{flag}=true but no credential ({sources})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n return super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n async def ainvoke(self, *args, **kwargs):\n try:\n return await call_with_timeout(\n super().ainvoke(*args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\n# Patterns matching secret-shaped substrings that providers may echo back\n# inside HTTP error bodies. ``str(exc)`` for LiteLLM/openai errors is built\n# from that body, so it can reflect the Authorization/x-api-key/Bearer\n# credential we sent. Each pattern below scrubs one shape before the text is\n# interpolated into a user-facing RuntimeError that lands in CLI output/logs.\n_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (\n # \"Bearer <token>\" auth scheme (RFC 6750), case-insensitive.\n (re.compile(r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"), \"Bearer [REDACTED]\"),\n # Provider key prefixes: Anthropic sk-ant-* and OpenAI-style sk-* (any\n # remaining long token after the prefix).\n (re.compile(r\"\\bsk-(?:ant-)?[\\w.\\-]{8,}\"), \"[REDACTED]\"),\n # Header/field values for authorization / api_key / x-api-key, whether\n # JSON (\"authorization\": \"...\") or kwarg (api_key=...). Keeps the key\n # name so the guidance stays readable; only the value is scrubbed.\n (\n re.compile(\n r\"(?i)(['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*)\"\n r\"(['\\\"]?)[^'\\\"\\s,}]+\\2\"\n ),\n r\"\\1\\2[REDACTED]\\2\",\n ),\n # Generic long opaque tokens (>=24 chars of key-ish alphabet) that survive\n # the targeted passes above — e.g. provider keys without an sk- prefix.\n (re.compile(r\"\\b[A-Za-z0-9_\\-]{24,}\\b\"), \"[REDACTED]\"),\n)\n\n\ndef _redact_secrets(text: str) -> str:\n \"\"\"Scrub credential-shaped substrings from upstream error text.\n\n LiteLLM/openai surface the provider's raw HTTP response body via\n ``str(exc)``; that body can echo the ``Authorization``/``x-api-key``/\n ``Bearer`` value we sent. Redacting here keeps the actionable guidance\n (status code, model id, human hint) intact while preventing the secret\n from leaking into CLI output and logs.\n \"\"\"\n for pattern, replacement in _SECRET_PATTERNS:\n text = pattern.sub(replacement, text)\n return text\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {_redact_secrets(str(exc))}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n # Match on the raw text (status codes / keywords are not secret-shaped),\n # but interpolate the scrubbed copy so an echoed credential never reaches\n # the user-facing message — see _redact_secrets.\n safe_msg = _redact_secrets(msg)\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {safe_msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n resolved_profile = profile if profile is not None else self._resolve_profile()\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n self._proxy.url,\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "d7796aba7589cab0f9a8967e87da6af0f7fffa68": {"t": 1785755468.3635912, "n": 54183, "h": "d6a4509f4775d234", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env > default ``600``.\n Rejects non-positive or non-numeric env values with ``ValueError`` so\n misconfiguration fails loudly rather than silently disabling the guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n if raw:\n value = float(raw)\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{LLM_TIMEOUT_ENV} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Any of ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint),\n ``OLLAMA_CLOUD_MODEL``, or a cloud API key\n (``OLLAMA_CLOUD_API_KEY`` / ``OLLAMA_API_KEY``) is enough to opt in.\n The key is included so a user who pasted only their key into\n `decepticon onboard` (relying on the default base + model) is still\n detected as a cloud user instead of silently falling through.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_API_KEY\", \"\").strip()\n or os.getenv(\"OLLAMA_API_KEY\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need BOTH the boolean intent and the actual\n # credential file (see ``_oauth_credentials_present`` for why).\n return _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")) and _oauth_credentials_present(\n method\n )\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n return f\"{flag}=true but no credential file ({paths or 'token'})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n return super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n async def ainvoke(self, *args, **kwargs):\n try:\n return await call_with_timeout(\n super().ainvoke(*args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {exc}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {msg}\"\n ) from exc\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n resolved_profile = profile if profile is not None else self._resolve_profile()\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n self._proxy.url,\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "335d42b99ceaf02adee4a24dfc6439c0131fa527": {"t": 1785755469.9121144, "n": 56057, "h": "e3a52da78bca448f", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nimport re\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env > default ``600``.\n Rejects non-positive or non-numeric env values with ``ValueError`` so\n misconfiguration fails loudly rather than silently disabling the guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n if raw:\n value = float(raw)\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{LLM_TIMEOUT_ENV} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Either ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_CLOUD_MODEL`` is enough to opt in.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need BOTH the boolean intent and the actual\n # credential file (see ``_oauth_credentials_present`` for why).\n return _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")) and _oauth_credentials_present(\n method\n )\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n return f\"{flag}=true but no credential file ({paths or 'token'})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n return super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n async def ainvoke(self, *args, **kwargs):\n try:\n return await call_with_timeout(\n super().ainvoke(*args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\n# Patterns matching secret-shaped substrings that providers may echo back\n# inside HTTP error bodies. ``str(exc)`` for LiteLLM/openai errors is built\n# from that body, so it can reflect the Authorization/x-api-key/Bearer\n# credential we sent. Each pattern below scrubs one shape before the text is\n# interpolated into a user-facing RuntimeError that lands in CLI output/logs.\n_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (\n # \"Bearer <token>\" auth scheme (RFC 6750), case-insensitive.\n (re.compile(r\"(?i)\\bBearer\\s+[\\w.\\-+/=]+\"), \"Bearer [REDACTED]\"),\n # Provider key prefixes: Anthropic sk-ant-* and OpenAI-style sk-* (any\n # remaining long token after the prefix).\n (re.compile(r\"\\bsk-(?:ant-)?[\\w.\\-]{8,}\"), \"[REDACTED]\"),\n # Header/field values for authorization / api_key / x-api-key, whether\n # JSON (\"authorization\": \"...\") or kwarg (api_key=...). Keeps the key\n # name so the guidance stays readable; only the value is scrubbed.\n (\n re.compile(\n r\"(?i)(['\\\"]?(?:authorization|x-api-key|api[_-]?key)['\\\"]?\\s*[:=]\\s*)\"\n r\"(['\\\"]?)[^'\\\"\\s,}]+\\2\"\n ),\n r\"\\1\\2[REDACTED]\\2\",\n ),\n # Generic long opaque tokens (>=24 chars of key-ish alphabet) that survive\n # the targeted passes above — e.g. provider keys without an sk- prefix.\n (re.compile(r\"\\b[A-Za-z0-9_\\-]{24,}\\b\"), \"[REDACTED]\"),\n)\n\n\ndef _redact_secrets(text: str) -> str:\n \"\"\"Scrub credential-shaped substrings from upstream error text.\n\n LiteLLM/openai surface the provider's raw HTTP response body via\n ``str(exc)``; that body can echo the ``Authorization``/``x-api-key``/\n ``Bearer`` value we sent. Redacting here keeps the actionable guidance\n (status code, model id, human hint) intact while preventing the secret\n from leaking into CLI output and logs.\n \"\"\"\n for pattern, replacement in _SECRET_PATTERNS:\n text = pattern.sub(replacement, text)\n return text\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {_redact_secrets(str(exc))}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n # Match on the raw text (status codes / keywords are not secret-shaped),\n # but interpolate the scrubbed copy so an echoed credential never reaches\n # the user-facing message — see _redact_secrets.\n safe_msg = _redact_secrets(msg)\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {safe_msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {safe_msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {safe_msg}\"\n ) from exc\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n resolved_profile = profile if profile is not None else self._resolve_profile()\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n self._proxy.url,\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "62c0afbe5d2495cf2ed156322f7d1c21b52292aa": {"t": 1785755471.953984, "n": 55684, "h": "67c3b5a60e2dba75", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env > default ``600``.\n Rejects non-positive or non-numeric env values with ``ValueError`` so\n misconfiguration fails loudly rather than silently disabling the guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n if raw:\n value = float(raw)\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{LLM_TIMEOUT_ENV} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n# Env vars each LiteLLM OAuth handler accepts as a credential *in place of*\n# its on-disk token file (see config/*_handler.py ``_load_tokens`` /\n# ``_env_override_tokens`` / ``_resolve_source_token``). A non-empty value in\n# any of these means the handler can authenticate without a file, so the\n# method must still count as configured. Codex (OPENAI_OAUTH) exposes no\n# env-token override — only the file-path env vars already covered by\n# ``_OAUTH_CREDENTIAL_PATHS`` — so it is intentionally absent here.\n_OAUTH_ENV_CREDENTIALS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\"ANTHROPIC_OAUTH_TOKEN\",),\n AuthMethod.GOOGLE_OAUTH: (\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"),\n AuthMethod.COPILOT_OAUTH: (\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"),\n AuthMethod.GROK_OAUTH: (\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"),\n AuthMethod.PERPLEXITY_OAUTH: (\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Either ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_CLOUD_MODEL`` is enough to opt in.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _oauth_env_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if any env var the handler accepts as a credential is set.\n\n Env-only OAuth setups (e.g. ``GEMINI_ACCESS_TOKEN`` with no token file)\n are valid: the LiteLLM handler authenticates straight from the env. Such\n a method must not be dropped from the fallback chain just because no file\n exists on disk.\n \"\"\"\n return any(os.getenv(env_var, \"\").strip() for env_var in _OAUTH_ENV_CREDENTIALS.get(method, ()))\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need the boolean intent AND a usable credential. The\n # credential may live in a token file OR in the handler's env-var\n # override — env-only setups are valid and must not be dropped.\n if not _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")):\n return False\n return _oauth_credentials_present(method) or _oauth_env_credentials_present(method)\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n if _oauth_env_credentials_present(method):\n return f\"{flag}=true and credential present (env)\"\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n env_vars = \", \".join(_OAUTH_ENV_CREDENTIALS.get(method, ()))\n sources = paths or \"token\"\n if env_vars:\n sources = f\"{sources} or {env_vars}\"\n return f\"{flag}=true but no credential ({sources})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n return super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n async def ainvoke(self, *args, **kwargs):\n try:\n return await call_with_timeout(\n super().ainvoke(*args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {exc}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {msg}\"\n ) from exc\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n resolved_profile = profile if profile is not None else self._resolve_profile()\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n self._proxy.url,\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "0f9b1a13d523a60561f829d7f81337946577c6af": {"t": 1785755473.602917, "n": 55698, "h": "035bdb4a7ffca31d", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env > default ``600``.\n Rejects non-positive or non-numeric env values with ``ValueError`` so\n misconfiguration fails loudly rather than silently disabling the guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n if raw:\n value = float(raw)\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{LLM_TIMEOUT_ENV} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n# Env vars each LiteLLM OAuth handler accepts as a credential *in place of*\n# its on-disk token file (see config/*_handler.py ``_load_tokens`` /\n# ``_env_override_tokens`` / ``_resolve_source_token``). A non-empty value in\n# any of these means the handler can authenticate without a file, so the\n# method must still count as configured. Codex (OPENAI_OAUTH) exposes no\n# env-token override — only the file-path env vars already covered by\n# ``_OAUTH_CREDENTIAL_PATHS`` — so it is intentionally absent here.\n_OAUTH_ENV_CREDENTIALS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\"ANTHROPIC_OAUTH_TOKEN\",),\n AuthMethod.GOOGLE_OAUTH: (\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"),\n AuthMethod.COPILOT_OAUTH: (\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"),\n AuthMethod.GROK_OAUTH: (\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"),\n AuthMethod.PERPLEXITY_OAUTH: (\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Either ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_CLOUD_MODEL`` is enough to opt in.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _oauth_env_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if any env var the handler accepts as a credential is set.\n\n Env-only OAuth setups (e.g. ``GEMINI_ACCESS_TOKEN`` with no token file)\n are valid: the LiteLLM handler authenticates straight from the env. Such\n a method must not be dropped from the fallback chain just because no file\n exists on disk.\n \"\"\"\n return any(\n os.getenv(env_var, \"\").strip() for env_var in _OAUTH_ENV_CREDENTIALS.get(method, ())\n )\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need the boolean intent AND a usable credential. The\n # credential may live in a token file OR in the handler's env-var\n # override — env-only setups are valid and must not be dropped.\n if not _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")):\n return False\n return _oauth_credentials_present(method) or _oauth_env_credentials_present(method)\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n if _oauth_env_credentials_present(method):\n return f\"{flag}=true and credential present (env)\"\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n env_vars = \", \".join(_OAUTH_ENV_CREDENTIALS.get(method, ()))\n sources = paths or \"token\"\n if env_vars:\n sources = f\"{sources} or {env_vars}\"\n return f\"{flag}=true but no credential ({sources})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n return super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n async def ainvoke(self, *args, **kwargs):\n try:\n return await call_with_timeout(\n super().ainvoke(*args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {exc}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {msg}\"\n ) from exc\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n resolved_profile = profile if profile is not None else self._resolve_profile()\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n self._proxy.url,\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "12b15640b45132c5bd90327a4fd6a80275acbdf7": {"t": 1785755476.3816288, "n": 53802, "h": "8a8c9648957534da", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env > default ``600``.\n Rejects non-positive or non-numeric env values with ``ValueError`` so\n misconfiguration fails loudly rather than silently disabling the guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n if raw:\n value = float(raw)\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{LLM_TIMEOUT_ENV} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. Every OAuth\n# (subscription) method precedes its closest paid-API peer so a subscription\n# is spent first and only falls back to the metered API when its quota hits —\n# never the other way. Copilot (broad multi-model subscription) sits just\n# after the OpenAI slot; Perplexity (search-augmented, niche for general\n# agent work) sits low. Local/cloud-local methods sit at the end: hosted\n# providers are usually preferred when both are available; locals are the\n# last-resort fallback.\n#\n# NOTE: all six subscription methods are listed here so a credential wired\n# without an explicit DECEPTICON_AUTH_PRIORITY is actually routed. Before,\n# google/copilot/grok/perplexity OAuth were absent — a configured\n# subscription was silently never used (surfaced by `decepticon-cli auth`).\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.COPILOT_OAUTH,\n AuthMethod.GOOGLE_OAUTH,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.GROK_OAUTH,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.PERPLEXITY_OAUTH,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Either ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_CLOUD_MODEL`` is enough to opt in.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need BOTH the boolean intent and the actual\n # credential file (see ``_oauth_credentials_present`` for why).\n return _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")) and _oauth_credentials_present(\n method\n )\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n return f\"{flag}=true but no credential file ({paths or 'token'})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n return super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n async def ainvoke(self, *args, **kwargs):\n try:\n return await call_with_timeout(\n super().ainvoke(*args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {exc}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {msg}\"\n ) from exc\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n resolved_profile = profile if profile is not None else self._resolve_profile()\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n self._proxy.url,\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "210b6ed3ff524fc2eb758a3120fe9508c7f21d07": {"t": 1785755477.8749976, "n": 53302, "h": "84f6f4fa010c78ae", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nfrom collections.abc import Awaitable\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env > default ``600``.\n Rejects non-positive or non-numeric env values with ``ValueError`` so\n misconfiguration fails loudly rather than silently disabling the guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n if raw:\n value = float(raw)\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{LLM_TIMEOUT_ENV} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. OAuth methods\n# precede the matching API method so a subscription primary falls back to\n# the paid API only when the subscription quota hits — not the other way.\n# OLLAMA_LOCAL sits at the end: cloud providers are usually preferred\n# (faster, smarter) when both are available; Ollama still gets picked up\n# as a last-resort fallback when its env vars are wired but no priority\n# list was authored.\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Either ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_CLOUD_MODEL`` is enough to opt in.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _method_is_configured(method: AuthMethod) -> bool:\n \"\"\"Return True if ``method``'s credential-detection rule passes.\n\n Single source of truth shared by ``_detect_available_methods`` (which\n builds the runtime fallback chain) and ``auth_inventory`` (which reports\n per-method status to the CLI / SDK / dashboard).\n \"\"\"\n if method in _API_METHOD_ENV:\n return _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method)\n if method in _OAUTH_METHOD_ENV:\n # OAuth methods need BOTH the boolean intent and the actual\n # credential file (see ``_oauth_credentials_present`` for why).\n return _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")) and _oauth_credentials_present(\n method\n )\n if method == AuthMethod.OLLAMA_LOCAL:\n return _ollama_local_configured()\n if method == AuthMethod.OLLAMA_CLOUD:\n return _ollama_cloud_configured()\n if method == AuthMethod.LMSTUDIO_LOCAL:\n return _lmstudio_local_configured()\n if method == AuthMethod.LLAMACPP_LOCAL:\n return _llamacpp_local_configured()\n if method == AuthMethod.CUSTOM_OPENAI_API:\n return _custom_openai_configured()\n return False\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n return [method for method in priority if _method_is_configured(method)]\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\n# ── Auth inventory (headless introspection for CLI / SDK / dashboard) ─────\n\n_SUBSCRIPTION_METHODS: frozenset[AuthMethod] = frozenset(_OAUTH_METHOD_ENV)\n_LOCAL_METHODS: frozenset[AuthMethod] = frozenset(\n {\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n }\n)\n\n# Signal env var for local/cloud endpoints (API + OAuth methods get theirs\n# from _API_METHOD_ENV / _OAUTH_METHOD_ENV).\n_LOCAL_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.OLLAMA_LOCAL: \"OLLAMA_API_BASE\",\n AuthMethod.OLLAMA_CLOUD: \"OLLAMA_CLOUD_API_BASE\",\n AuthMethod.LMSTUDIO_LOCAL: \"LMSTUDIO_API_BASE\",\n AuthMethod.LLAMACPP_LOCAL: \"LLAMACPP_API_BASE\",\n AuthMethod.CUSTOM_OPENAI_API: \"CUSTOM_OPENAI_API_BASE\",\n}\n\n_METHOD_LABEL: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"Anthropic — API key\",\n AuthMethod.ANTHROPIC_OAUTH: \"Anthropic — Claude Code (Max/Pro/Team)\",\n AuthMethod.OPENAI_API: \"OpenAI — API key\",\n AuthMethod.OPENAI_OAUTH: \"OpenAI — ChatGPT (Pro/Plus/Team)\",\n AuthMethod.GOOGLE_API: \"Google — Gemini API key\",\n AuthMethod.GOOGLE_OAUTH: \"Google — Gemini Advanced (Google One)\",\n AuthMethod.MINIMAX_API: \"MiniMax — API key\",\n AuthMethod.DEEPSEEK_API: \"DeepSeek — API key\",\n AuthMethod.XAI_API: \"xAI — API key\",\n AuthMethod.MISTRAL_API: \"Mistral — API key\",\n AuthMethod.OPENROUTER_API: \"OpenRouter — API key\",\n AuthMethod.NVIDIA_API: \"NVIDIA NIM — API key\",\n AuthMethod.GROQ_API: \"Groq — API key\",\n AuthMethod.TOGETHER_API: \"Together AI — API key\",\n AuthMethod.FIREWORKS_API: \"Fireworks AI — API key\",\n AuthMethod.COHERE_API: \"Cohere — API key\",\n AuthMethod.MOONSHOT_API: \"Moonshot (Kimi) — API key\",\n AuthMethod.ZAI_API: \"Z.ai (GLM) — API key\",\n AuthMethod.DASHSCOPE_API: \"Alibaba DashScope (Qwen) — API key\",\n AuthMethod.GITHUB_MODELS_API: \"GitHub Models — PAT\",\n AuthMethod.BEDROCK_API: \"AWS Bedrock — AWS credentials\",\n AuthMethod.VERTEX_API: \"GCP Vertex AI — service account\",\n AuthMethod.AZURE_API: \"Azure OpenAI — API key\",\n AuthMethod.CEREBRAS_API: \"Cerebras — API key\",\n AuthMethod.XIAOMI_MIMO_API: \"Xiaomi MiMo — API key\",\n AuthMethod.COPILOT_OAUTH: \"GitHub Copilot — Pro subscription\",\n AuthMethod.GROK_OAUTH: \"xAI SuperGrok — X Premium+\",\n AuthMethod.PERPLEXITY_OAUTH: \"Perplexity — Pro subscription\",\n AuthMethod.OLLAMA_LOCAL: \"Ollama — local\",\n AuthMethod.OLLAMA_CLOUD: \"Ollama Cloud — API key\",\n AuthMethod.LMSTUDIO_LOCAL: \"LM Studio — local\",\n AuthMethod.LLAMACPP_LOCAL: \"llama.cpp — local\",\n AuthMethod.CUSTOM_OPENAI_API: \"Custom OpenAI-compatible endpoint\",\n}\n\n\ndef _method_kind(method: AuthMethod) -> str:\n if method in _SUBSCRIPTION_METHODS:\n return \"subscription\"\n if method in _LOCAL_METHODS:\n return \"local\"\n return \"api\"\n\n\ndef _method_env_var(method: AuthMethod) -> str:\n if method in _API_METHOD_ENV:\n return _API_METHOD_ENV[method]\n if method in _OAUTH_METHOD_ENV:\n return _OAUTH_METHOD_ENV[method]\n return _LOCAL_METHOD_ENV.get(method, \"\")\n\n\ndef _method_detail(method: AuthMethod, *, configured: bool) -> str:\n \"\"\"One-line hint: confirmation when configured, remediation otherwise.\"\"\"\n env = _method_env_var(method)\n if method in _OAUTH_METHOD_ENV:\n flag = _OAUTH_METHOD_ENV[method]\n if configured:\n return f\"{flag}=true and credential file present\"\n if not _is_truthy(os.getenv(flag, \"\")):\n return f\"set {flag}=true and provide the subscription credential\"\n paths = \", \".join(default for _env, default in _OAUTH_CREDENTIAL_PATHS.get(method, ()))\n return f\"{flag}=true but no credential file ({paths or 'token'})\"\n if method in _LOCAL_METHODS or method == AuthMethod.OLLAMA_CLOUD:\n return f\"{env} set\" if configured else f\"set {env} (and the matching *_MODEL)\"\n if configured:\n return f\"{env} set\"\n prefixes = _KEY_PREFIX_HINTS.get(method)\n hint = f\" (starts with {prefixes[0]!r})\" if prefixes else \"\"\n return f\"set {env}{hint}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthMethodStatus:\n \"\"\"Configuration status of one :class:`AuthMethod`.\"\"\"\n\n method: AuthMethod\n kind: str # \"api\" | \"subscription\" | \"local\"\n label: str\n env_var: str\n configured: bool\n in_priority: bool\n active: bool # configured AND on the resolved fallback chain\n detail: str\n\n\n@dataclass(frozen=True, slots=True)\nclass AuthInventory:\n \"\"\"Snapshot of every auth method's status + the resolved fallback chain.\"\"\"\n\n statuses: tuple[AuthMethodStatus, ...]\n resolved_chain: tuple[AuthMethod, ...]\n priority_explicit: bool\n\n @property\n def configured(self) -> tuple[AuthMethodStatus, ...]:\n return tuple(s for s in self.statuses if s.configured)\n\n @property\n def any_active(self) -> bool:\n return bool(self.resolved_chain)\n\n @property\n def configured_but_idle(self) -> tuple[AuthMethodStatus, ...]:\n \"\"\"Configured methods NOT on the resolved chain — almost always\n because the method is missing from ``DECEPTICON_AUTH_PRIORITY``\n (google_oauth / copilot_oauth / grok_oauth / perplexity_oauth are\n not in the default priority), so the credential is wired but the\n runtime will never route to it.\"\"\"\n return tuple(s for s in self.statuses if s.configured and not s.active)\n\n\ndef auth_inventory() -> AuthInventory:\n \"\"\"Introspect provider/auth configuration from the environment.\n\n Pure env + credential-file inspection (no network, no model\n construction), safe to call from the CLI, SDK, web dashboard, or a CI\n preflight. Reuses the same detection rules the runtime factory uses, so\n what this reports is exactly what the agent will route to.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n priority_set = set(priority)\n resolved = _detect_available_methods(priority)\n resolved_set = set(resolved)\n\n statuses = [\n AuthMethodStatus(\n method=method,\n kind=_method_kind(method),\n label=_METHOD_LABEL.get(method, method.value),\n env_var=_method_env_var(method),\n configured=_method_is_configured(method),\n in_priority=method in priority_set,\n active=method in resolved_set,\n detail=_method_detail(method, configured=_method_is_configured(method)),\n )\n for method in AuthMethod\n ]\n return AuthInventory(\n statuses=tuple(statuses),\n resolved_chain=tuple(resolved),\n priority_explicit=priority_explicit,\n )\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n return super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n async def ainvoke(self, *args, **kwargs):\n try:\n return await call_with_timeout(\n super().ainvoke(*args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {exc}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {msg}\"\n ) from exc\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n resolved_profile = profile if profile is not None else self._resolve_profile()\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n self._proxy.url,\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "31e796bb04c0b5395ea353930ccced57e1262d23": {"t": 1785755479.487016, "n": 46777, "h": "9f20f36758ee4a86", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nfrom collections.abc import Awaitable\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env > default ``600``.\n Rejects non-positive or non-numeric env values with ``ValueError`` so\n misconfiguration fails loudly rather than silently disabling the guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n if raw:\n value = float(raw)\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{LLM_TIMEOUT_ENV} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(f\"LLM request timed out after {timeout:g} seconds\") from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. OAuth methods\n# precede the matching API method so a subscription primary falls back to\n# the paid API only when the subscription quota hits — not the other way.\n# OLLAMA_LOCAL sits at the end: cloud providers are usually preferred\n# (faster, smarter) when both are available; Ollama still gets picked up\n# as a last-resort fallback when its env vars are wired but no priority\n# list was authored.\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Either ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_CLOUD_MODEL`` is enough to opt in.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n methods: list[AuthMethod] = []\n for method in priority:\n if method in _API_METHOD_ENV:\n if _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method):\n methods.append(method)\n elif method in _OAUTH_METHOD_ENV:\n # OAuth methods need BOTH the boolean intent and the actual\n # credential file. Without the file check a stale flag (e.g.\n # ``codex logout`` after onboard) generates a 401-fallback\n # storm — see ``_oauth_credentials_present`` for the full\n # rationale.\n if _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")) and _oauth_credentials_present(\n method\n ):\n methods.append(method)\n elif method == AuthMethod.OLLAMA_LOCAL:\n if _ollama_local_configured():\n methods.append(method)\n elif method == AuthMethod.OLLAMA_CLOUD:\n if _ollama_cloud_configured():\n methods.append(method)\n elif method == AuthMethod.LMSTUDIO_LOCAL:\n if _lmstudio_local_configured():\n methods.append(method)\n elif method == AuthMethod.LLAMACPP_LOCAL:\n if _llamacpp_local_configured():\n methods.append(method)\n elif method == AuthMethod.CUSTOM_OPENAI_API:\n if _custom_openai_configured():\n methods.append(method)\n return methods\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n return super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n async def ainvoke(self, *args, **kwargs):\n try:\n return await call_with_timeout(\n super().ainvoke(*args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {exc}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {msg}\"\n ) from exc\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n resolved_profile = profile if profile is not None else self._resolve_profile()\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n self._proxy.url,\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "eb456230f6bccb2300e14aa2097b2bc9c022ba71": {"t": 1785755481.0904179, "n": 46799, "h": "ad5d9dc6838c9d6d", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nfrom collections.abc import Awaitable\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\nDEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 600\nLLM_TIMEOUT_ENV = \"DECEPTICON_LLM_TIMEOUT_SECONDS\"\n\n\nclass LLMTimeoutError(RuntimeError):\n \"\"\"Raised when an async LLM request exceeds the configured per-call timeout.\n\n Distinct from generic ``asyncio.TimeoutError`` so middleware and retry\n layers can identify request-timeout failures without catching every\n cancellation in the loop. The timeout is whole-coroutine, not transport-\n level (``ProxyConfig.timeout`` already covers transport).\n \"\"\"\n\n\ndef _resolve_llm_timeout_seconds() -> float:\n \"\"\"Resolve the per-call LLM request timeout.\n\n Precedence: ``DECEPTICON_LLM_TIMEOUT_SECONDS`` env > default ``600``.\n Rejects non-positive or non-numeric env values with ``ValueError`` so\n misconfiguration fails loudly rather than silently disabling the guard.\n \"\"\"\n raw = os.getenv(LLM_TIMEOUT_ENV, \"\").strip()\n if raw:\n value = float(raw)\n else:\n value = float(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS)\n if value <= 0:\n raise ValueError(f\"{LLM_TIMEOUT_ENV} must be greater than 0\")\n return value\n\n\nasync def call_with_timeout(coro: Awaitable[Any], timeout: float) -> Any:\n \"\"\"Wrap ``coro`` in :func:`asyncio.wait_for` and re-raise as :class:`LLMTimeoutError`.\n\n The translation preserves the original ``asyncio.TimeoutError`` via\n ``__cause__`` so debugging traces remain intact, while letting upstream\n code distinguish provider-stall timeouts from generic cancellation.\n \"\"\"\n try:\n return await asyncio.wait_for(coro, timeout=timeout)\n except asyncio.TimeoutError as exc:\n raise LLMTimeoutError(\n f\"LLM request timed out after {timeout:g} seconds\"\n ) from exc\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. OAuth methods\n# precede the matching API method so a subscription primary falls back to\n# the paid API only when the subscription quota hits — not the other way.\n# OLLAMA_LOCAL sits at the end: cloud providers are usually preferred\n# (faster, smarter) when both are available; Ollama still gets picked up\n# as a last-resort fallback when its env vars are wired but no priority\n# list was authored.\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Either ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_CLOUD_MODEL`` is enough to opt in.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n methods: list[AuthMethod] = []\n for method in priority:\n if method in _API_METHOD_ENV:\n if _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method):\n methods.append(method)\n elif method in _OAUTH_METHOD_ENV:\n # OAuth methods need BOTH the boolean intent and the actual\n # credential file. Without the file check a stale flag (e.g.\n # ``codex logout`` after onboard) generates a 401-fallback\n # storm — see ``_oauth_credentials_present`` for the full\n # rationale.\n if _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")) and _oauth_credentials_present(\n method\n ):\n methods.append(method)\n elif method == AuthMethod.OLLAMA_LOCAL:\n if _ollama_local_configured():\n methods.append(method)\n elif method == AuthMethod.OLLAMA_CLOUD:\n if _ollama_cloud_configured():\n methods.append(method)\n elif method == AuthMethod.LMSTUDIO_LOCAL:\n if _lmstudio_local_configured():\n methods.append(method)\n elif method == AuthMethod.LLAMACPP_LOCAL:\n if _llamacpp_local_configured():\n methods.append(method)\n elif method == AuthMethod.CUSTOM_OPENAI_API:\n if _custom_openai_configured():\n methods.append(method)\n return methods\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n return super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n async def ainvoke(self, *args, **kwargs):\n try:\n return await call_with_timeout(\n super().ainvoke(*args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n except LLMTimeoutError:\n raise\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await call_with_timeout(\n super()._agenerate(messages, *args, **kwargs),\n _resolve_llm_timeout_seconds(),\n )\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {exc}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {msg}\"\n ) from exc\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n resolved_profile = profile if profile is not None else self._resolve_profile()\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n self._proxy.url,\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "7c57d82ce62bd031bd303570ad26766cbbd90a0a": {"t": 1785755482.7150664, "n": 44791, "h": "0dd753289a2de196", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport os\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. OAuth methods\n# precede the matching API method so a subscription primary falls back to\n# the paid API only when the subscription quota hits — not the other way.\n# OLLAMA_LOCAL sits at the end: cloud providers are usually preferred\n# (faster, smarter) when both are available; Ollama still gets picked up\n# as a last-resort fallback when its env vars are wired but no priority\n# list was authored.\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Either ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_CLOUD_MODEL`` is enough to opt in.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _parse_auth_priority() -> tuple[list[AuthMethod], bool]:\n \"\"\"Parse ``DECEPTICON_AUTH_PRIORITY`` into an ordered AuthMethod list.\n\n Returns ``(priority, priority_explicit)``: the ordered method list and a\n flag for whether the env var was set non-empty. When it is unset/blank\n the default ordering (``_DEFAULT_AUTH_PRIORITY``) is returned. Unknown\n tokens are logged and skipped.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n return priority, priority_explicit\n\n\ndef _detect_available_methods(priority: list[AuthMethod]) -> list[AuthMethod]:\n \"\"\"Return the subset of ``priority`` whose credential-detection rule passes.\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is truthy AND the credential\n file is present\n - Local methods: their own env signal is configured\n \"\"\"\n methods: list[AuthMethod] = []\n for method in priority:\n if method in _API_METHOD_ENV:\n if _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method):\n methods.append(method)\n elif method in _OAUTH_METHOD_ENV:\n # OAuth methods need BOTH the boolean intent and the actual\n # credential file. Without the file check a stale flag (e.g.\n # ``codex logout`` after onboard) generates a 401-fallback\n # storm — see ``_oauth_credentials_present`` for the full\n # rationale.\n if _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")) and _oauth_credentials_present(\n method\n ):\n methods.append(method)\n elif method == AuthMethod.OLLAMA_LOCAL:\n if _ollama_local_configured():\n methods.append(method)\n elif method == AuthMethod.OLLAMA_CLOUD:\n if _ollama_cloud_configured():\n methods.append(method)\n elif method == AuthMethod.LMSTUDIO_LOCAL:\n if _lmstudio_local_configured():\n methods.append(method)\n elif method == AuthMethod.LLAMACPP_LOCAL:\n if _llamacpp_local_configured():\n methods.append(method)\n elif method == AuthMethod.CUSTOM_OPENAI_API:\n if _custom_openai_configured():\n methods.append(method)\n return methods\n\n\ndef _fallback_credentials(*, priority_explicit: bool) -> Credentials:\n \"\"\"Build the fallback Credentials when no priority method was detected.\n\n A user who wired only a local/cloud OpenAI-compatible endpoint (Ollama,\n LM Studio, llama.cpp, custom) but authored no priority list gets a\n single-method chain for it. Otherwise falls back to all API methods so\n module-level agent constructors stay importable; ``priority_explicit``\n only changes the log severity (ERROR vs INFO).\n \"\"\"\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\"),\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority, priority_explicit = _parse_auth_priority()\n\n methods = _detect_available_methods(priority)\n\n if not methods:\n return _fallback_credentials(priority_explicit=priority_explicit)\n\n return Credentials(methods=methods)\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n return super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n async def ainvoke(self, *args, **kwargs):\n try:\n return await super().ainvoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await super()._agenerate(messages, *args, **kwargs)\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {exc}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {msg}\"\n ) from exc\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n resolved_profile = profile if profile is not None else self._resolve_profile()\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n self._proxy.url,\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}, "04d7fa11d42a2d51530936806a2faa7579d1f601": {"t": 1785755484.9436398, "n": 43446, "h": "05fe3942ac034a84", "c": "\"\"\"LLM Factory — creates ChatModel instances via LiteLLM proxy.\n\nAll LLM calls route through the LiteLLM Docker proxy for provider abstraction.\nProvider API keys are configured in .env / docker-compose.yml.\n\nArchitecture:\n LLMFactory(proxy, mapping)\n → get_model(\"recon\") → ChatOpenAI(model=\"anthropic/claude-haiku-4-5\")\n → get_fallback_models(\"recon\") → [ChatOpenAI(model=\"openai/gpt-5-nano\")]\n ↓\n LiteLLM proxy → Anthropic/OpenAI/Google/etc.\n\nProfile-aware: when no explicit mapping is provided, builds a\ncredentials-aware mapping from environment variables. The factory\ninspects which credentials are configured (non-placeholder API keys\nplus the OAuth toggle) and respects ``DECEPTICON_AUTH_PRIORITY`` for\nordering AuthMethods in the fallback chain.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport os\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import AIMessage, BaseMessage\nfrom langchain_openai import ChatOpenAI\nfrom pydantic import SecretStr\n\nfrom decepticon.llm.router import ModelRouter\nfrom decepticon_core.registry import RoleRegistry\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\nfrom decepticon_core.utils.logging import get_logger\n\nlog = get_logger(\"llm.factory\")\n\n\n# Default ordering when DECEPTICON_AUTH_PRIORITY is not set. OAuth methods\n# precede the matching API method so a subscription primary falls back to\n# the paid API only when the subscription quota hits — not the other way.\n# OLLAMA_LOCAL sits at the end: cloud providers are usually preferred\n# (faster, smarter) when both are available; Ollama still gets picked up\n# as a last-resort fallback when its env vars are wired but no priority\n# list was authored.\n_DEFAULT_AUTH_PRIORITY: tuple[AuthMethod, ...] = (\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_OAUTH,\n AuthMethod.OPENAI_API,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n AuthMethod.GROQ_API,\n AuthMethod.TOGETHER_API,\n AuthMethod.FIREWORKS_API,\n AuthMethod.COHERE_API,\n AuthMethod.MOONSHOT_API,\n AuthMethod.ZAI_API,\n AuthMethod.DASHSCOPE_API,\n AuthMethod.GITHUB_MODELS_API,\n AuthMethod.BEDROCK_API,\n AuthMethod.VERTEX_API,\n AuthMethod.AZURE_API,\n AuthMethod.LMSTUDIO_LOCAL,\n AuthMethod.LLAMACPP_LOCAL,\n AuthMethod.CUSTOM_OPENAI_API,\n AuthMethod.CEREBRAS_API,\n AuthMethod.XIAOMI_MIMO_API,\n AuthMethod.OLLAMA_LOCAL,\n AuthMethod.OLLAMA_CLOUD,\n)\n\n# Each AuthMethod's detection rule:\n# - API methods: presence of a non-placeholder env var\n# - OAuth methods: an explicit \"true\" boolean env var (set by the\n# onboard wizard after a successful OAuth handshake)\n# - Local methods: their own env signal (OLLAMA_API_BASE for Ollama)\n_API_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_API: \"ANTHROPIC_API_KEY\",\n AuthMethod.OPENAI_API: \"OPENAI_API_KEY\",\n AuthMethod.GOOGLE_API: \"GEMINI_API_KEY\",\n AuthMethod.MINIMAX_API: \"MINIMAX_API_KEY\",\n AuthMethod.DEEPSEEK_API: \"DEEPSEEK_API_KEY\",\n AuthMethod.XAI_API: \"XAI_API_KEY\",\n AuthMethod.MISTRAL_API: \"MISTRAL_API_KEY\",\n AuthMethod.OPENROUTER_API: \"OPENROUTER_API_KEY\",\n AuthMethod.NVIDIA_API: \"NVIDIA_API_KEY\",\n # Cloud gateways added in the OpenClaude provider migration. Each\n # routes through LiteLLM's native provider implementation when the\n # corresponding API key is present and not a placeholder.\n AuthMethod.GROQ_API: \"GROQ_API_KEY\",\n AuthMethod.TOGETHER_API: \"TOGETHER_API_KEY\",\n AuthMethod.FIREWORKS_API: \"FIREWORKS_API_KEY\",\n AuthMethod.COHERE_API: \"COHERE_API_KEY\",\n AuthMethod.MOONSHOT_API: \"MOONSHOT_API_KEY\",\n AuthMethod.ZAI_API: \"ZAI_API_KEY\",\n AuthMethod.DASHSCOPE_API: \"DASHSCOPE_API_KEY\",\n AuthMethod.GITHUB_MODELS_API: \"GITHUB_TOKEN\",\n AuthMethod.BEDROCK_API: \"AWS_ACCESS_KEY_ID\",\n # Vertex uses a service-account JSON path; treat the path env var as\n # the credential signal so onboard's \"real key\" check works on it.\n AuthMethod.VERTEX_API: \"GOOGLE_APPLICATION_CREDENTIALS\",\n AuthMethod.AZURE_API: \"AZURE_API_KEY\",\n AuthMethod.CEREBRAS_API: \"CEREBRAS_API_KEY\",\n AuthMethod.XIAOMI_MIMO_API: \"XIAOMI_MIMO_API_KEY\",\n}\n\n_OAUTH_METHOD_ENV: dict[AuthMethod, str] = {\n AuthMethod.ANTHROPIC_OAUTH: \"DECEPTICON_AUTH_CLAUDE_CODE\",\n AuthMethod.OPENAI_OAUTH: \"DECEPTICON_AUTH_CHATGPT\",\n AuthMethod.GOOGLE_OAUTH: \"DECEPTICON_AUTH_GEMINI\",\n AuthMethod.COPILOT_OAUTH: \"DECEPTICON_AUTH_COPILOT\",\n AuthMethod.GROK_OAUTH: \"DECEPTICON_AUTH_GROK\",\n AuthMethod.PERPLEXITY_OAUTH: \"DECEPTICON_AUTH_PERPLEXITY\",\n}\n\n# Vendor-specific API key prefix hints. When the method has a known\n# canonical prefix, ``_is_real_key`` requires the value to start with one\n# of these strings — protects against placeholder strings the launcher\n# didn't emit (e.g. a user pasting ``sk-ant-not-used`` into .env).\n_KEY_PREFIX_HINTS: dict[AuthMethod, tuple[str, ...]] = {\n AuthMethod.ANTHROPIC_API: (\"sk-ant-\",),\n AuthMethod.OPENAI_API: (\"sk-\",),\n AuthMethod.GOOGLE_API: (\"AIza\",),\n AuthMethod.XAI_API: (\"xai-\",),\n AuthMethod.GROQ_API: (\"gsk_\",),\n AuthMethod.OPENROUTER_API: (\"sk-or-\",),\n AuthMethod.NVIDIA_API: (\"nvapi-\",),\n AuthMethod.DEEPSEEK_API: (\"sk-\",),\n AuthMethod.GITHUB_MODELS_API: (\"ghp_\", \"github_pat_\", \"gho_\", \"ghs_\"),\n}\n\n# Substring tokens that mark a value as obviously not a real key.\n# Catches creative placeholder values that don't match the launcher's\n# ``your-…-key-here`` template.\n_PLACEHOLDER_TOKENS: tuple[str, ...] = (\n \"placeholder\",\n \"not-used\",\n \"not_used\",\n \"dummy\",\n \"fake\",\n \"example\",\n)\n\n# Minimum length for any value that should be treated as a real key. All\n# vendor-issued keys exceed this — Anthropic ``sk-ant-api03-…`` ≈ 100 chars,\n# OpenAI ``sk-…`` ≥ 48 chars, Google ``AIza…`` 39 chars. 24 leaves headroom\n# for vendors with shorter formats (Mistral, etc.) without admitting\n# obviously-junk values.\n_KEY_MIN_LENGTH = 24\n\n# OAuth methods carry a host-side credentials file. Booleans like\n# ``DECEPTICON_AUTH_CLAUDE_CODE=true`` are intent (the user enabled the\n# subscription) — they don't guarantee the actual file exists. The\n# factory verifies file presence + valid JSON before adding a method to\n# the chain so a user who ran ``codex logout`` without flipping the\n# boolean back doesn't generate a noisy 401-fallback storm.\n#\n# Each tuple is ordered: primary path first, legacy paths after. Env-var\n# overrides take precedence over the literal default. The langgraph\n# compose service mounts the Claude + Codex paths read-only so this\n# check sees the same files the LiteLLM handlers will read.\n_OAUTH_CREDENTIAL_PATHS: dict[AuthMethod, tuple[tuple[str, str], ...]] = {\n AuthMethod.ANTHROPIC_OAUTH: (\n (\"CLAUDE_CODE_CREDENTIALS_PATH\", \"~/.claude/.credentials.json\"),\n (\"\", \"~/.config/anthropic/q/tokens.json\"), # legacy emulator path\n ),\n AuthMethod.OPENAI_OAUTH: ((\"CODEX_AUTH_PATH\", \"~/.codex/auth.json\"),),\n AuthMethod.GOOGLE_OAUTH: ((\"GEMINI_TOKENS_PATH\", \"~/.config/gemini/tokens.json\"),),\n AuthMethod.COPILOT_OAUTH: ((\"COPILOT_TOKENS_PATH\", \"~/.config/copilot/tokens.json\"),),\n AuthMethod.GROK_OAUTH: ((\"GROK_TOKENS_PATH\", \"~/.config/grok/tokens.json\"),),\n AuthMethod.PERPLEXITY_OAUTH: ((\"PERPLEXITY_TOKENS_PATH\", \"~/.config/perplexity/tokens.json\"),),\n}\n\n\ndef _ollama_cloud_configured() -> bool:\n \"\"\"Return True when the user has wired up Ollama Cloud.\n\n Either ``OLLAMA_CLOUD_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_CLOUD_MODEL`` is enough to opt in.\n \"\"\"\n return bool(\n os.getenv(\"OLLAMA_CLOUD_API_BASE\", \"\").strip()\n or os.getenv(\"OLLAMA_CLOUD_MODEL\", \"\").strip()\n )\n\n\ndef _ollama_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local Ollama.\n\n Either ``OLLAMA_API_BASE`` (preferred — explicit endpoint) or\n ``OLLAMA_MODEL`` (a pulled model id) is enough to opt in. Both\n blank → not configured. Empty/whitespace strings are treated as\n \"not set\" so a stray ``OLLAMA_API_BASE=`` line in .env doesn't\n silently enable the method.\n \"\"\"\n return bool(os.getenv(\"OLLAMA_API_BASE\", \"\").strip() or os.getenv(\"OLLAMA_MODEL\", \"\").strip())\n\n\ndef _lmstudio_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local LM Studio.\"\"\"\n return bool(\n os.getenv(\"LMSTUDIO_API_BASE\", \"\").strip() or os.getenv(\"LMSTUDIO_MODEL\", \"\").strip()\n )\n\n\ndef _llamacpp_local_configured() -> bool:\n \"\"\"Return True when the user has wired up local llama.cpp llama-server.\n\n Either ``LLAMACPP_API_BASE`` (preferred — explicit endpoint, e.g.\n ``http://localhost:8080/v1``) or ``LLAMACPP_MODEL`` (a logical model\n name) is enough to opt in. ``LLAMACPP_API_KEY`` is *not* required —\n llama-server accepts any string by default and an unset key resolves\n to a literal placeholder via LiteLLM's env interpolation, which the\n server happily accepts. See issue #151.\n \"\"\"\n return bool(\n os.getenv(\"LLAMACPP_API_BASE\", \"\").strip() or os.getenv(\"LLAMACPP_MODEL\", \"\").strip()\n )\n\n\ndef _custom_openai_configured() -> bool:\n \"\"\"Return True when the user has wired up a custom OpenAI-compatible\n endpoint. Both ``CUSTOM_OPENAI_API_BASE`` (URL) and\n ``CUSTOM_OPENAI_API_KEY`` (real, non-placeholder) are required —\n a base URL alone won't authenticate, and a key without a URL has\n nowhere to point.\n \"\"\"\n base = os.getenv(\"CUSTOM_OPENAI_API_BASE\", \"\").strip()\n key = os.getenv(\"CUSTOM_OPENAI_API_KEY\", \"\")\n return bool(base) and _is_real_key(key)\n\n\ndef _is_real_key(value: str, method: AuthMethod | None = None) -> bool:\n \"\"\"Validate that ``value`` looks like a real provider API key.\n\n Layers, in order:\n 1. Strip whitespace; reject empty.\n 2. Reject anything shorter than ``_KEY_MIN_LENGTH`` (24 chars) —\n every vendor-issued key exceeds this, while typical placeholders\n (``sk-ant-test``, ``not-set``) do not.\n 3. Reject the launcher's template strings (``your-…-key-here``).\n 4. Reject values containing obvious placeholder tokens\n (``placeholder``, ``not-used``, ``dummy``, …) — guards against\n creative .env values that escape the launcher template.\n 5. When ``method`` is given and ``_KEY_PREFIX_HINTS`` defines a\n canonical prefix for it, require ``value`` to start with one of\n the prefixes. Catches mis-pasted keys (e.g. an OpenAI key in\n the Anthropic slot) before they propagate into the chain.\n\n ``method=None`` skips the prefix check — kept for callers like\n ``_custom_openai_configured`` where the vendor's expected prefix is\n deployment-specific (any OpenAI-compatible gateway).\n \"\"\"\n v = value.strip()\n if not v or len(v) < _KEY_MIN_LENGTH:\n return False\n lower = v.lower()\n if lower.startswith(\"your-\") or lower.endswith(\"-key-here\"):\n return False\n if any(token in lower for token in _PLACEHOLDER_TOKENS):\n return False\n if method is not None:\n prefixes = _KEY_PREFIX_HINTS.get(method)\n if prefixes and not any(v.startswith(prefix) for prefix in prefixes):\n return False\n return True\n\n\ndef _oauth_credentials_present(method: AuthMethod) -> bool:\n \"\"\"Return True if the host-side credential file for ``method`` exists.\n\n The factory layer reads this to keep the credentials inventory\n honest — without it, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place the OAuth\n method in every fallback chain, generating one 401 per request.\n\n Each path is checked in order. ``/dev/null`` (the docker-compose\n fallback when no credentials volume is wired) parses as empty, so\n the JSON-validation step fails closed.\n \"\"\"\n candidates = _OAUTH_CREDENTIAL_PATHS.get(method)\n if not candidates:\n # Method has no documented file path — fall back to the boolean\n # flag alone for forward compatibility.\n return True\n for env_var, default in candidates:\n raw = os.environ.get(env_var, \"\").strip() if env_var else \"\"\n path = Path(raw).expanduser() if raw else Path(default).expanduser()\n try:\n text = path.read_text()\n except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):\n continue\n except OSError:\n # ``/dev/null`` reads to empty without raising; other transient\n # I/O errors fall through to the next candidate.\n text = \"\"\n text = text.strip()\n if not text:\n continue\n try:\n data = json.loads(text)\n except json.JSONDecodeError:\n continue\n if isinstance(data, dict) and data:\n return True\n return False\n\n\ndef _is_truthy(value: str) -> bool:\n return value.strip().lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef _resolve_credentials() -> Credentials:\n \"\"\"Build Credentials from environment variables.\n\n Walks ``DECEPTICON_AUTH_PRIORITY`` (comma-separated AuthMethod\n values; defaults to ``_DEFAULT_AUTH_PRIORITY``) and includes only\n methods whose detection rule passes:\n\n - API methods: their key env var is set to a non-placeholder\n - OAuth methods: their boolean env var is set truthy\n\n When **nothing** is detected — typical of CI / dev shells where\n onboard hasn't run — falls back to all four API methods. This keeps\n module-level ``graph = create_X_agent()`` calls importable so the\n test suite (and tools like langgraph Studio) can load agents\n without API keys present. Real LLM calls under that fallback will\n fail at request time with a provider 401, which is the correct\n surface for that misconfiguration.\n \"\"\"\n priority_raw = os.getenv(\"DECEPTICON_AUTH_PRIORITY\", \"\")\n priority_explicit = bool(priority_raw.strip())\n if priority_explicit:\n priority: list[AuthMethod] = []\n for token in priority_raw.split(\",\"):\n token = token.strip().lower()\n if not token:\n continue\n try:\n priority.append(AuthMethod(token))\n except ValueError:\n log.warning(\"Unknown method in DECEPTICON_AUTH_PRIORITY: %s\", token)\n else:\n priority = list(_DEFAULT_AUTH_PRIORITY)\n\n methods: list[AuthMethod] = []\n for method in priority:\n if method in _API_METHOD_ENV:\n if _is_real_key(os.getenv(_API_METHOD_ENV[method], \"\"), method):\n methods.append(method)\n elif method in _OAUTH_METHOD_ENV:\n # OAuth methods need BOTH the boolean intent and the actual\n # credential file. Without the file check a stale flag (e.g.\n # ``codex logout`` after onboard) generates a 401-fallback\n # storm — see ``_oauth_credentials_present`` for the full\n # rationale.\n if _is_truthy(os.getenv(_OAUTH_METHOD_ENV[method], \"\")) and _oauth_credentials_present(\n method\n ):\n methods.append(method)\n elif method == AuthMethod.OLLAMA_LOCAL:\n if _ollama_local_configured():\n methods.append(method)\n elif method == AuthMethod.OLLAMA_CLOUD:\n if _ollama_cloud_configured():\n methods.append(method)\n elif method == AuthMethod.LMSTUDIO_LOCAL:\n if _lmstudio_local_configured():\n methods.append(method)\n elif method == AuthMethod.LLAMACPP_LOCAL:\n if _llamacpp_local_configured():\n methods.append(method)\n elif method == AuthMethod.CUSTOM_OPENAI_API:\n if _custom_openai_configured():\n methods.append(method)\n\n if not methods:\n # Local-only or cloud-only OSS path: a user who set Ollama env vars\n # but didn't write a priority list gets a single-method Ollama chain.\n if _ollama_local_configured():\n log.info(\n \"Only OLLAMA_API_BASE/OLLAMA_MODEL detected; \"\n \"running against local Ollama exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_LOCAL])\n if _ollama_cloud_configured():\n log.info(\n \"Only OLLAMA_CLOUD_API_BASE/OLLAMA_CLOUD_MODEL detected; \"\n \"running against Ollama Cloud exclusively\"\n )\n return Credentials(methods=[AuthMethod.OLLAMA_CLOUD])\n if _lmstudio_local_configured():\n log.info(\"Only LMSTUDIO_API_BASE/LMSTUDIO_MODEL detected; using LM Studio\")\n return Credentials(methods=[AuthMethod.LMSTUDIO_LOCAL])\n if _llamacpp_local_configured():\n log.info(\"Only LLAMACPP_API_BASE/LLAMACPP_MODEL detected; using llama.cpp\")\n return Credentials(methods=[AuthMethod.LLAMACPP_LOCAL])\n if _custom_openai_configured():\n log.info(\"Only CUSTOM_OPENAI_* detected; using custom OpenAI-compatible endpoint\")\n return Credentials(methods=[AuthMethod.CUSTOM_OPENAI_API])\n if priority_explicit:\n # User expressed clear intent (set DECEPTICON_AUTH_PRIORITY) but\n # every listed method failed detection. Surface the root cause\n # at ERROR level — otherwise the silent fallback to\n # all_api_methods() runs through providers the user doesn't\n # have, producing a confusing 401 cascade (often masked as a\n # downstream \"rate limit (429)\" once the routed-to provider\n # cools down). Return behavior preserved so module imports\n # stay green; real model calls still surface a remediation\n # hint via _reraise_with_actionable_message.\n log.error(\n \"DECEPTICON_AUTH_PRIORITY=%r set but no listed method has \"\n \"detectable credentials. Verify: (1) API keys are \"\n \"non-placeholder (e.g. ANTHROPIC_API_KEY starts with \"\n \"'sk-ant-'), (2) OAuth flag matches credential file \"\n \"(e.g. DECEPTICON_AUTH_CLAUDE_CODE=true requires \"\n \"~/.claude/.credentials.json to exist and contain a valid \"\n \"JSON object — a /dev/null mount fails this check). \"\n \"Falling back to all-API-methods so module imports \"\n \"remain importable; every model call will 401 until \"\n \"the priority chain is fixed.\",\n priority_raw,\n )\n else:\n log.info(\n \"No credentials detected in environment; using all-API-methods \"\n \"fallback so module-level agent constructors stay importable\"\n )\n return Credentials.all_api_methods()\n\n return Credentials(methods=methods)\n\n\nclass _ProxiedChatOpenAI(ChatOpenAI):\n \"\"\"Translate opaque transport/upstream errors into actionable RuntimeError\n messages so LangGraph's serde surfaces something the user can fix instead\n of the generic 'An internal error occurred' wrapper they see in the OSS\n issue tracker.\n\n Two failure surfaces matter:\n\n 1. **Connection errors** — proxy unreachable. Almost always a Docker\n networking or container-health problem; we point the user at the\n logs.\n 2. **Upstream provider errors** — 4xx returned by the actual model\n provider (Anthropic/OpenAI/Ollama/...) and bubbled through LiteLLM.\n These carry a meaningful message but hit the LangGraph runner as\n a generic ``openai.BadRequestError`` whose serialized form gets\n flattened to 'internal error' on the way back to the CLI. We pull\n out the original message and rewrap it.\n \"\"\"\n\n def invoke(self, *args, **kwargs):\n try:\n return super().invoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n async def ainvoke(self, *args, **kwargs):\n try:\n return await super().ainvoke(*args, **kwargs)\n except Exception as exc:\n _reraise_with_actionable_message(exc, self.model_name)\n raise\n\n\ndef _model_drops_temperature(model: str) -> bool:\n \"\"\"Return True if the LiteLLM model id rejects the ``temperature`` param.\n\n Anthropic deprecated ``temperature`` for Claude Opus 4.7 — the request\n gets a 400 from the upstream API regardless of the proxy path. Match\n on the Opus 4.x family across every namespace we route through:\n\n anthropic/claude-opus-4-7\n auth/claude-opus-4-7\n openrouter/anthropic/claude-opus-4-7\n\n Rather than enumerate paths we look at the model slug suffix, which\n keeps this honest for openrouter-mirrored variants and any future\n Opus 4.x build added to METHOD_MODELS.\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug.startswith(\"claude-opus-4\")\n\n\ndef _model_is_deepseek_thinking(model: str) -> bool:\n \"\"\"Return True for DeepSeek V4 models and legacy deepseek-reasoner.\n\n DeepSeek V4 (pro **and** flash) plus legacy deepseek-reasoner use\n thinking mode by default and return ``reasoning_content`` in assistant\n messages. The API **requires** ``reasoning_content`` to be passed back\n in subsequent tool turns — omitting it triggers the upstream 400:\n \"The reasoning_content in the thinking mode must be passed back to the API.\"\n DeepSeek's own docs state ``deepseek-reasoner`` is the deprecated alias\n for ``deepseek-v4-flash`` thinking mode, so flash also needs this\n treatment. Closes #201, #220.\n See: https://api-docs.deepseek.com/guides/thinking_mode\n \"\"\"\n slug = model.rsplit(\"/\", 1)[-1].lower()\n return slug in (\"deepseek-v4-pro\", \"deepseek-v4-flash\", \"deepseek-reasoner\")\n\n\ndef _model_is_nvidia_nim(model: str) -> bool:\n \"\"\"Return True for any nvidia_nim/* route.\n\n NVIDIA NIM's OpenAI-compatible endpoint rejects ``messages[].content``\n as a list-of-parts (the OpenAI v1 multimodal shape) with:\n 400 invalid_request_error\n loc=('body','messages',0,'content')\n msg=\"Input should be a valid string\"\n LangChain ChatOpenAI serializes structured content (tool results,\n multipart system blocks) as that exact list shape, so every agent\n run on an NIM route 400s on the first turn. Flatten list-of-text\n parts to a single string before the request leaves the proxy.\n \"\"\"\n return model.lower().startswith(\"nvidia_nim/\")\n\n\nclass _DeepSeekThinkingChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that preserves DeepSeek ``reasoning_content``.\n\n DeepSeek V4 Pro's thinking mode returns ``reasoning_content`` alongside\n ``content`` in assistant messages. When tool calls are present, this field\n **must** be passed back in all subsequent API requests. LangChain's default\n message converters silently drop it in both directions:\n\n 1. Response → AIMessage: ``reasoning_content`` is not extracted\n 2. AIMessage → request dict: ``reasoning_content`` is not serialized\n\n This class patches both directions by:\n - Storing ``reasoning_content`` in ``AIMessage.additional_kwargs``\n - Injecting it back into request dicts for assistant messages\n - Passing ``extra_body={\"thinking\": {\"type\": \"enabled\"}}`` and\n ``reasoning_effort=\"high\"`` on every request\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Inject reasoning_content into outbound assistant messages.\"\"\"\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n\n # Inject DeepSeek thinking mode params\n extra_body = payload.get(\"extra_body\") or {}\n extra_body[\"thinking\"] = {\"type\": \"enabled\"}\n payload[\"extra_body\"] = extra_body\n payload[\"reasoning_effort\"] = \"high\"\n\n # Walk the messages array and inject reasoning_content from\n # additional_kwargs back into assistant message dicts so the\n # DeepSeek API sees them.\n for msg in payload.get(\"messages\", []):\n if msg.get(\"role\") != \"assistant\":\n continue\n # The source AIMessage stashes reasoning_content in\n # additional_kwargs; _convert_message_to_dict does not\n # serialize it. Find the original AIMessage and inject.\n # We also check if the dict already has it (future-proofing\n # in case LangChain adds native support).\n if \"reasoning_content\" not in msg:\n # Try to find matching AIMessage from the input\n if isinstance(input_, list):\n for lc_msg in input_:\n if isinstance(lc_msg, AIMessage) and lc_msg.additional_kwargs.get(\n \"reasoning_content\"\n ):\n # Match by content — the dict's content came from this message\n msg_content = msg.get(\"content\") or \"\"\n lc_content = lc_msg.content or \"\"\n if str(msg_content) == str(lc_content) or (\n msg.get(\"tool_calls\") and lc_msg.tool_calls\n ):\n msg[\"reasoning_content\"] = lc_msg.additional_kwargs[\n \"reasoning_content\"\n ]\n break\n\n return payload\n\n def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _generate to preserve reasoning_content in the response.\"\"\"\n result = super()._generate(messages, *args, **kwargs)\n # _create_chat_result already handled extraction; this is a no-op safety net.\n return result\n\n async def _agenerate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any:\n \"\"\"Wrap _agenerate to preserve reasoning_content in the response.\"\"\"\n result = await super()._agenerate(messages, *args, **kwargs)\n return result\n\n def _convert_chunk_to_generation_chunk(\n self,\n chunk: dict,\n default_chunk_class: type,\n base_generation_info: dict | None,\n ) -> Any:\n \"\"\"Intercept streaming chunks to capture ``reasoning_content``.\n\n DeepSeek sends ``reasoning_content`` inside ``choices[0].delta``\n during streaming. LangChain's ``_convert_delta_to_message_chunk``\n ignores it, so it never reaches ``AIMessageChunk.additional_kwargs``.\n\n We call the parent to build the ``ChatGenerationChunk`` normally,\n then fish ``reasoning_content`` out of the raw delta dict and inject\n it into the chunk message's ``additional_kwargs``. When LangChain\n aggregates chunks via ``AIMessageChunk.__add__``, ``merge_dicts``\n concatenates the string fragments into the full reasoning trace,\n which ``_get_request_payload`` then injects back into the API\n request on subsequent turns.\n \"\"\"\n gen_chunk = super()._convert_chunk_to_generation_chunk(\n chunk, default_chunk_class, base_generation_info\n )\n if gen_chunk is None:\n return None\n\n # Extract reasoning_content from the raw delta\n choices = chunk.get(\"choices\") or chunk.get(\"chunk\", {}).get(\"choices\", [])\n if choices:\n delta = choices[0].get(\"delta\") or {}\n rc = delta.get(\"reasoning_content\")\n if rc and isinstance(gen_chunk.message, AIMessage):\n gen_chunk.message.additional_kwargs.setdefault(\"reasoning_content\", \"\")\n gen_chunk.message.additional_kwargs[\"reasoning_content\"] += rc\n\n return gen_chunk\n\n def _create_chat_result(self, response: Any, generation_info: dict | None = None) -> Any:\n \"\"\"Override to capture ``reasoning_content`` from the response dict.\n\n ``_create_chat_result`` receives either the raw OpenAI ``ChatCompletion``\n object or its ``.model_dump()`` dict. Either way, each choice's\n ``message`` dict contains ``reasoning_content`` (the OpenAI SDK v1.x\n preserves it via ``model_extra``). LangChain's ``_convert_dict_to_message``\n ignores it, so we fish it out of the response dict and inject it into\n the resulting ``AIMessage.additional_kwargs`` after the parent builds\n the ``ChatResult``.\n \"\"\"\n # Get the response as a dict so we can access reasoning_content\n import openai as _openai\n\n if isinstance(response, _openai.BaseModel):\n response_dict = response.model_dump(\n exclude={\"choices\": {\"__all__\": {\"message\": {\"parsed\"}}}}\n )\n elif isinstance(response, dict):\n response_dict = response\n else:\n response_dict = {}\n\n result = super()._create_chat_result(response, generation_info)\n\n # Pair up choices with generations and inject reasoning_content\n choices = response_dict.get(\"choices\") or []\n for choice, generation in zip(choices, result.generations):\n msg = getattr(generation, \"message\", None)\n if not isinstance(msg, AIMessage):\n continue\n if msg.additional_kwargs.get(\"reasoning_content\"):\n continue\n rc = (choice.get(\"message\") or {}).get(\"reasoning_content\")\n if rc:\n msg.additional_kwargs[\"reasoning_content\"] = rc\n\n return result\n\n\nclass _NvidiaNIMChatOpenAI(_ProxiedChatOpenAI):\n \"\"\"ChatOpenAI subclass that flattens content to a string for NVIDIA NIM.\n\n NIM's OpenAI-compat endpoint requires ``messages[].content`` to be a\n plain string. LangChain ChatOpenAI emits the OpenAI v1 multipart shape\n (``[{\"type\":\"text\",\"text\":\"...\"}, ...]``) whenever a message has more\n than one part (tool result + reasoning preamble, multi-block system).\n NIM 400s on that shape. Re-pack each list into the concatenated text\n just before the request leaves the proxy. Image/audio parts are\n preserved untouched — those still get rejected upstream, but the\n error then becomes the upstream feature-gap rather than a contract\n mismatch.\n \"\"\"\n\n def _get_request_payload(\n self,\n input_: Any,\n *,\n stop: list[str] | None = None,\n **kwargs: Any,\n ) -> dict:\n payload = super()._get_request_payload(input_, stop=stop, **kwargs)\n for msg in payload.get(\"messages\", []):\n content = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n text_parts: list[str] = []\n non_text: list[Any] = []\n for part in content:\n if isinstance(part, dict):\n if part.get(\"type\") in (None, \"text\", \"input_text\", \"output_text\"):\n text = part.get(\"text\") or part.get(\"content\") or \"\"\n if isinstance(text, str) and text:\n text_parts.append(text)\n continue\n non_text.append(part)\n elif isinstance(part, str):\n if part:\n text_parts.append(part)\n else:\n non_text.append(part)\n if non_text:\n # Leave multimodal parts to the upstream provider's\n # rejection — collapsing them would silently drop data.\n continue\n msg[\"content\"] = \"\".join(text_parts)\n return payload\n\n\ndef _reraise_if_connection_error(exc: Exception) -> None:\n err_type = type(exc).__name__\n if any(\n kw in err_type.lower() for kw in (\"connect\", \"timeout\", \"refused\", \"unreachable\")\n ) or any(\n kw in str(exc).lower()\n for kw in (\"connection refused\", \"connect error\", \"proxy\", \"unreachable\")\n ):\n raise RuntimeError(\n f\"LLM proxy unreachable ({err_type}): {exc}. \"\n f\"Check 'decepticon logs litellm' for details.\"\n ) from exc\n\n\ndef _reraise_with_actionable_message(exc: Exception, model_name: str) -> None:\n \"\"\"Translate transport + upstream errors into a useful RuntimeError.\n\n Connection failures still go through ``_reraise_if_connection_error``\n (the original signal). For 4xx errors that LiteLLM forwards from the\n upstream provider, we extract the inner message — LiteLLM nests it\n inside the response payload — and re-raise with a clear \"model X\n failed because Y\" framing plus a remediation hint.\n\n Critically, this is the place to disambiguate the four classes the\n OSS user actually sees as 'internal error':\n\n - 400 BadRequestError (e.g. deprecated param, model_group not found)\n - 401 AuthenticationError (key missing/invalid for the routed provider)\n - 404 NotFoundError (model not registered in litellm.yaml)\n - 429 RateLimitError (provider quota hit)\n \"\"\"\n _reraise_if_connection_error(exc)\n\n err_type = type(exc).__name__\n msg = str(exc)\n msg_lower = msg.lower()\n\n # LiteLLM puts a recognizable prefix in the inner message when the\n # proxy ran out of fallback options for a model_group — issue #107.\n # Surface this distinctly so users know *why* the request couldn't be\n # retried somewhere else.\n if \"no fallback model group found\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' failed and no provider fallback was \"\n f\"available for it. Either configure another auth method in \"\n f\"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\\n\"\n f\"Underlying: {msg}\"\n ) from exc\n\n if \"badrequest\" in err_type.lower() or \"code: 400\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' rejected the request (400). \"\n f\"This usually means a parameter the model no longer supports \"\n f\"(e.g. temperature on Claude Opus 4.7). Underlying: {msg}\"\n ) from exc\n\n if (\n \"authentication\" in err_type.lower()\n or \"code: 401\" in msg_lower\n or \"invalid_api_key\" in msg_lower\n ):\n raise RuntimeError(\n f\"Model '{model_name}' rejected your credentials (401). \"\n f\"Check the API key for that provider in ~/.decepticon/.env, \"\n f\"or run 'decepticon onboard --reset'.\\nUnderlying: {msg}\"\n ) from exc\n\n if \"ratelimit\" in err_type.lower() or \"code: 429\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' hit the provider's rate limit (429). \"\n f\"Add another method to DECEPTICON_AUTH_PRIORITY so the agent \"\n f\"can fall back when this happens.\\nUnderlying: {msg}\"\n ) from exc\n\n if \"notfound\" in err_type.lower() or \"code: 404\" in msg_lower:\n raise RuntimeError(\n f\"Model '{model_name}' is not registered in the LiteLLM proxy \"\n f\"(404). For local Ollama, set OLLAMA_MODEL to something you \"\n f\"actually pulled ('ollama list'). For cloud providers, check \"\n f\"that the model id matches config/litellm.yaml.\\n\"\n f\"Underlying: {msg}\"\n ) from exc\n\n\nclass LLMFactory:\n \"\"\"Creates and caches LangChain ChatModel instances per agent role.\n\n Routes all models through LiteLLM proxy. Supports primary + fallback\n model resolution via ModelRouter.\n\n When constructed without an explicit mapping, builds one from the\n user's credentials inventory and the model profile from\n DecepticonConfig (env: ``DECEPTICON_MODEL_PROFILE``).\n \"\"\"\n\n def __init__(\n self,\n proxy: ProxyConfig | None = None,\n mapping: LLMModelMapping | None = None,\n credentials: Credentials | None = None,\n profile: ModelProfile | str | None = None,\n ):\n self._proxy = proxy or self._resolve_proxy_config()\n if mapping is not None:\n self._mapping = mapping\n else:\n creds = credentials if credentials is not None else _resolve_credentials()\n resolved_profile = profile if profile is not None else self._resolve_profile()\n self._mapping = LLMModelMapping.from_credentials_and_profile(creds, resolved_profile)\n self._router = ModelRouter(self._mapping)\n self._cache: dict[str, BaseChatModel] = {}\n\n @staticmethod\n def _resolve_proxy_config() -> ProxyConfig:\n \"\"\"Resolve proxy config from DecepticonConfig (env vars).\"\"\"\n from decepticon_core.utils.config import load_config\n\n config = load_config()\n return ProxyConfig(\n url=config.llm.proxy_url,\n api_key=config.llm.proxy_api_key,\n timeout=config.llm.timeout,\n max_retries=config.llm.max_retries,\n )\n\n @staticmethod\n def _resolve_profile() -> ModelProfile:\n \"\"\"Resolve the model profile from DecepticonConfig (env var).\"\"\"\n from decepticon_core.utils.config import load_config\n\n return load_config().model_profile\n\n @property\n def proxy_url(self) -> str:\n return self._proxy.url\n\n @property\n def router(self) -> ModelRouter:\n return self._router\n\n def _resolve_default_role(self, role: str, explicit: str | None) -> str | None:\n \"\"\"Pick ``default_role`` for ``ModelRouter.get_assignment``.\n\n Resolution order:\n 1. caller-supplied ``explicit`` wins (existing behavior)\n 2. otherwise consult ``RoleRegistry`` for an\n ``llm_role_fallback`` registered against ``role``\n 3. otherwise None (router will raise if it can't resolve)\n\n Closes spec §8 gap #5 — plugin-shipped roles (e.g. SaaS\n ``apt``) no longer need every call site to thread\n ``default_role`` through manually; the registration spec\n carries it.\n \"\"\"\n if explicit is not None:\n return explicit\n spec = RoleRegistry.get(role)\n if spec is None:\n return None\n return spec.llm_role_fallback\n\n def get_model(self, role: str, *, default_role: str | None = None) -> BaseChatModel:\n \"\"\"Get the primary ChatModel for a role. Cached per role.\n\n ``default_role`` lets plugin orchestrators inherit an OSS role's\n assignment when their custom role is not in ``AGENT_TIERS`` —\n e.g. ``LLMFactory().get_model(\"decepticon-pro\", default_role=\"decepticon\")``.\n\n When the caller leaves ``default_role`` unset, the factory consults\n ``decepticon_core.registry.RoleRegistry`` for the role's registered\n ``llm_role_fallback`` and uses that. This is how plugin-shipped\n roles such as the SaaS ``apt`` orchestrator stop needing every call\n site to thread ``default_role=\"decepticon\"`` through manually\n (closes spec §8 gap #5).\n \"\"\"\n if role in self._cache:\n return self._cache[role]\n\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n log.info(\n \"Creating LLM for role '%s' → model '%s' via %s\",\n role,\n assignment.primary,\n self._proxy.url,\n )\n\n model = self._create_chat_model(assignment.primary, assignment.temperature)\n self._cache[role] = model\n return model\n\n def get_fallback_models(\n self, role: str, *, default_role: str | None = None\n ) -> list[BaseChatModel]:\n \"\"\"Build the full ordered list of fallback ChatModel instances.\n\n Each entry mirrors one entry from the agent's credentials chain\n beyond the primary. The agent passes the result via\n ``ModelFallbackMiddleware(*models)``, which tries them in order\n until one succeeds. ``default_role`` works as in ``get_model``.\n \"\"\"\n default_role = self._resolve_default_role(role, default_role)\n assignment = self._router.get_assignment(role, default_role=default_role)\n if not assignment.fallbacks:\n return []\n\n log.info(\n \"Creating %d fallback LLM(s) for role '%s' → %s\",\n len(assignment.fallbacks),\n role,\n assignment.fallbacks,\n )\n return [\n self._create_chat_model(model, assignment.temperature) for model in assignment.fallbacks\n ]\n\n def _create_chat_model(self, model: str, temperature: float) -> BaseChatModel:\n \"\"\"Create a proxied ChatOpenAI instance routed through LiteLLM proxy.\n\n Claude Opus 4.7+ rejects ``temperature`` with a 400 invalid_request\n error regardless of how the request is routed — the model is wired\n to extended-thinking defaults that don't accept the parameter.\n Match every Opus 4.7 surface (anthropic/, auth/, openrouter/...)\n and drop the field from the OpenAI request payload via\n ``disabled_params``. Just omitting our own kwarg is not enough —\n ChatOpenAI synthesizes a default temperature when the field is\n unset, so the param still goes on the wire.\n\n The LiteLLM proxy also drops temperature for opus entries via\n ``additional_drop_params`` (config/litellm.yaml) — that's the\n belt-and-suspenders for any future client that bypasses this\n factory.\n \"\"\"\n kwargs: dict[str, Any] = {\n \"model\": model,\n \"base_url\": self._proxy.url,\n \"api_key\": SecretStr(self._proxy.api_key),\n \"timeout\": self._proxy.timeout,\n \"max_retries\": self._proxy.max_retries,\n }\n if _model_drops_temperature(model):\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n elif _model_is_deepseek_thinking(model):\n # DeepSeek V4 Pro thinking mode rejects temperature.\n kwargs[\"disabled_params\"] = {\"temperature\": None}\n else:\n kwargs[\"temperature\"] = temperature\n if _model_is_deepseek_thinking(model):\n return _DeepSeekThinkingChatOpenAI(**kwargs)\n if _model_is_nvidia_nim(model):\n return _NvidiaNIMChatOpenAI(**kwargs)\n return _ProxiedChatOpenAI(**kwargs)\n\n async def health_check(self) -> bool:\n \"\"\"Check if the LiteLLM proxy is reachable.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=5) as client:\n resp = await client.get(f\"{self._proxy.url}/health\")\n return resp.status_code == 200\n except Exception:\n return False\n\n\ndef create_llm(\n role: object,\n config: object | None = None,\n profile: ModelProfile | str | None = None,\n) -> BaseChatModel:\n \"\"\"Convenience function — creates primary model for a role.\n\n Backward-compatible wrapper around LLMFactory.\n The `config` parameter is accepted but ignored (kept for call-site compat).\n Pass `profile` to override the config-level model profile.\n \"\"\"\n _ = config\n factory = LLMFactory(profile=profile)\n role_str = str(role.value) if isinstance(role, Enum) else str(role)\n return factory.get_model(role_str)\n"}}, "packages/decepticon/tests/unit/llm/test_factory.py": {"42b918525fb79065e730da3da997bffb33fcd414": {"t": 1785746559.3738532, "n": 63899, "h": "e457ce1d43975348", "c": "\"\"\"Unit tests for decepticon.llm.factory.\"\"\"\n\nimport asyncio\nimport logging\n\nimport pytest\n\nfrom decepticon.llm.factory import (\n LLM_MAX_TOKENS_ENV,\n LLMFactory,\n _is_real_key,\n _llamacpp_local_configured,\n _method_is_configured,\n _model_max_output_tokens,\n _oauth_credentials_present,\n _oauth_env_credentials_present,\n _resolve_credentials,\n _resolve_max_tokens,\n)\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\n\n\nclass TestIsRealKey:\n \"\"\"Vendor-aware API key validation.\n\n The launcher writes ``your-…-key-here`` placeholders into .env so the\n user can later swap in a real key. The factory needs to reject those\n plus any obvious junk (short strings, ``placeholder``/``not-used``\n markers, vendor-prefix mismatches) so the credentials inventory\n reflects what actually works at request time.\n \"\"\"\n\n def test_rejects_empty_and_placeholder_template(self) -> None:\n assert _is_real_key(\"\") is False\n assert _is_real_key(\" \") is False\n assert _is_real_key(\"your-anthropic-key-here\") is False\n assert _is_real_key(\"YOUR-OPENAI-KEY-HERE\") is False # case-insensitive\n\n def test_rejects_short_strings(self) -> None:\n # Under 24 chars — every vendor-issued key exceeds this.\n assert _is_real_key(\"sk-tooshort\") is False\n\n def test_rejects_placeholder_tokens_in_value(self) -> None:\n long_enough = \"x\" * 30\n for token in (\"placeholder\", \"not-used\", \"dummy\", \"fake\", \"example\"):\n assert _is_real_key(f\"sk-{token}-{long_enough}\") is False, token\n\n def test_accepts_realistic_keys_without_method(self) -> None:\n # Without method context, prefix check is skipped.\n assert _is_real_key(\"sk-ant-api03-realtokenfortestingauthrouting12345\") is True\n assert _is_real_key(\"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\") is True\n\n def test_rejects_wrong_vendor_prefix(self) -> None:\n # An OpenAI-shaped key in the Anthropic slot must be caught.\n openai_key = \"sk-proj-realopenaitokenfortestingauthrouting12345\"\n assert _is_real_key(openai_key, AuthMethod.ANTHROPIC_API) is False\n # …and vice versa.\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.GOOGLE_API) is False\n\n def test_accepts_correct_vendor_prefix(self) -> None:\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.ANTHROPIC_API) is True\n google_key = \"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\"\n assert _is_real_key(google_key, AuthMethod.GOOGLE_API) is True\n\n\nclass TestOAuthCredentialsPresent:\n \"\"\"OAuth detection requires the credential file alongside the boolean.\n\n Without the file check, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place OAuth in\n every fallback chain and generate one 401 per request.\n \"\"\"\n\n def test_returns_false_when_file_missing(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(tmp_path / \"absent.json\"))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_when_file_is_empty(self, monkeypatch, tmp_path) -> None:\n # ``/dev/null``-style mounts read as empty — must fail closed.\n empty = tmp_path / \"empty.json\"\n empty.write_text(\"\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(empty))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_on_invalid_json(self, monkeypatch, tmp_path) -> None:\n bad = tmp_path / \"bad.json\"\n bad.write_text(\"{not-json\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(bad))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_true_when_file_is_well_formed(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"credentials.json\"\n good.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeef\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is True\n\n def test_codex_path_via_env_override(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"auth.json\"\n good.write_text('{\"tokens\": {\"access_token\": \"ABC\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CODEX_AUTH_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.OPENAI_OAUTH) is True\n\n\nclass TestOAuthEnvCredentials:\n \"\"\"B12: env-backed OAuth creds count as configured, not just on-disk files.\n Before the fix, ``_method_is_configured`` required a credential *file* even\n though the LiteLLM handlers also accept env-var tokens (GEMINI_ACCESS_TOKEN,\n GROK_SESSION_TOKEN, COPILOT_REFRESH_TOKEN, …). Env-only setups were silently\n dropped from the fallback chain.\n \"\"\"\n\n def test_env_present_returns_true_for_each_method(self, monkeypatch) -> None:\n cases = {\n AuthMethod.GOOGLE_OAUTH: \"GEMINI_ACCESS_TOKEN\",\n AuthMethod.COPILOT_OAUTH: \"COPILOT_REFRESH_TOKEN\",\n AuthMethod.GROK_OAUTH: \"GROK_SESSION_TOKEN\",\n AuthMethod.PERPLEXITY_OAUTH: \"PERPLEXITY_SESSION_TOKEN\",\n AuthMethod.ANTHROPIC_OAUTH: \"ANTHROPIC_OAUTH_TOKEN\",\n }\n for method, env_var in cases.items():\n monkeypatch.setenv(env_var, \"tok-from-env\")\n assert _oauth_env_credentials_present(method) is True\n monkeypatch.delenv(env_var, raising=False)\n\n def test_env_only_method_is_configured_without_file(self, monkeypatch, tmp_path) -> None:\n # Flag true + env token, but the credential file is absent.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.setenv(\"DECEPTICON_AUTH_GEMINI\", \"true\")\n monkeypatch.setenv(\"GEMINI_ACCESS_TOKEN\", \"ya29.env-token\")\n assert _oauth_credentials_present(AuthMethod.GOOGLE_OAUTH) is False\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is True\n\n def test_method_not_configured_without_file_or_env(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.setenv(\"DECEPTICON_AUTH_GEMINI\", \"true\")\n monkeypatch.delenv(\"GEMINI_ACCESS_TOKEN\", raising=False)\n monkeypatch.delenv(\"GEMINI_SESSION_COOKIES\", raising=False)\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is False\n\n def test_env_creds_still_require_intent_flag(self, monkeypatch, tmp_path) -> None:\n # Env token present but the boolean intent flag is off → not configured.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.delenv(\"DECEPTICON_AUTH_GEMINI\", raising=False)\n monkeypatch.setenv(\"GEMINI_ACCESS_TOKEN\", \"ya29.env-token\")\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is False\n\n def test_codex_has_no_env_token_override(self, monkeypatch) -> None:\n # Codex authenticates only from its file; no env-token override exists.\n monkeypatch.setenv(\"CODEX_ACCESS_TOKEN\", \"should-not-count\")\n assert _oauth_env_credentials_present(AuthMethod.OPENAI_OAUTH) is False\n\n\nclass TestMaxTokensResolution:\n \"\"\"Per-model output-token ceiling (fixes write_file truncation).\"\"\"\n\n def test_opus_and_sonnet_get_128k(self) -> None:\n assert _model_max_output_tokens(\"anthropic/claude-opus-4-8\") == 128000\n assert _model_max_output_tokens(\"auth/claude-opus-4-8\") == 128000\n assert _model_max_output_tokens(\"openrouter/anthropic/claude-sonnet-4-6\") == 128000\n\n def test_haiku_gets_64k(self) -> None:\n assert _model_max_output_tokens(\"anthropic/claude-haiku-4-5\") == 64000\n\n def test_unknown_model_falls_back_to_safe_default(self) -> None:\n assert _model_max_output_tokens(\"openai/gpt-5-nano\") == 64000\n\n def test_env_override_wins(self, monkeypatch) -> None:\n monkeypatch.setenv(LLM_MAX_TOKENS_ENV, \"8192\")\n # Override wins even for a model whose own ceiling is higher.\n assert _resolve_max_tokens(\"anthropic/claude-opus-4-8\") == 8192\n\n def test_invalid_env_falls_back_to_per_model(self, monkeypatch) -> None:\n monkeypatch.setenv(LLM_MAX_TOKENS_ENV, \"not-a-number\")\n assert _resolve_max_tokens(\"anthropic/claude-opus-4-8\") == 128000\n\n def test_no_env_uses_per_model(self, monkeypatch) -> None:\n monkeypatch.delenv(LLM_MAX_TOKENS_ENV, raising=False)\n assert _resolve_max_tokens(\"anthropic/claude-haiku-4-5\") == 64000\n\n\nclass TestLLMFactory:\n def setup_method(self):\n self.proxy = ProxyConfig(url=\"http://localhost:4000\", api_key=\"test-key\")\n # Build an explicit mapping so the test doesn't depend on env vars.\n creds = Credentials.all_api_methods()\n self.mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n self.factory = LLMFactory(self.proxy, self.mapping)\n\n def test_factory_initializes(self):\n assert self.factory.proxy_url == \"http://localhost:4000\"\n\n def test_get_model_returns_chat_model(self):\n model = self.factory.get_model(\"recon\")\n assert model is not None\n assert model.model_name == \"anthropic/claude-haiku-4-5\"\n\n def test_get_model_caches_instances(self):\n m1 = self.factory.get_model(\"recon\")\n m2 = self.factory.get_model(\"recon\")\n assert m1 is m2\n\n def test_get_model_different_roles_different_models(self):\n recon = self.factory.get_model(\"recon\")\n decepticon = self.factory.get_model(\"decepticon\")\n assert recon is not decepticon\n assert recon.model_name != decepticon.model_name\n\n def test_get_model_unknown_role_raises(self):\n with pytest.raises(KeyError, match=\"No model assignment\"):\n self.factory.get_model(\"nonexistent\")\n\n def test_router_accessible(self):\n assert self.factory.router is not None\n\n def test_get_fallback_models_full_chain(self):\n models = self.factory.get_fallback_models(\"recon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5-nano\",\n \"gemini/gemini-2.5-flash-lite\",\n \"deepseek/deepseek-v4-flash\",\n \"openrouter/anthropic/claude-haiku-4-5\",\n \"nvidia_nim/meta/llama-3.2-3b-instruct\",\n ]\n\n def test_get_fallback_models_high_tier_includes_all_methods(self):\n models = self.factory.get_fallback_models(\"decepticon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5.5\",\n \"gemini/gemini-2.5-pro\",\n \"minimax/MiniMax-M3\",\n \"deepseek/deepseek-v4-pro\",\n \"xai/grok-4.3\",\n \"mistral/mistral-large-latest\",\n \"openrouter/anthropic/claude-opus-4-8\",\n \"nvidia_nim/meta/llama-3.3-70b-instruct\",\n ]\n\n def test_get_fallback_models_without_fallback(self):\n # Single-credential mapping → no fallback.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n factory = LLMFactory(self.proxy, mapping)\n assert factory.get_fallback_models(\"recon\") == []\n\n def test_explicit_credentials_param(self):\n # Constructor accepts a Credentials object instead of a full mapping.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n factory = LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n assert factory.get_model(\"decepticon\").model_name == \"openai/gpt-5.5\"\n\n\nclass TestLLMFactoryEmptyChainValidation:\n \"\"\"A credentials inventory whose AuthMethods lack a (method, tier) entry\n for some required tier resolves to an EMPTY model chain at that tier.\n ``LLMModelMapping.from_credentials_and_profile`` silently skips such\n roles, so the failure surfaces much later — either as a confusing\n ``KeyError: No model assignment for role: ...`` at ``get_model`` time\n or, when a caller routes through middleware, as a primary-less\n ``ModelFallbackMiddleware([])``. Validate at construction so the\n operator gets one actionable error naming the empty tier(s) and the\n configured credentials inventory.\"\"\"\n\n def setup_method(self) -> None:\n self.proxy = ProxyConfig(url=\"http://localhost:4000\", api_key=\"test-key\")\n\n def test_empty_tier_raises_at_init_with_inventory_listed(self) -> None:\n # MINIMAX_API has only HIGH+MID entries in METHOD_MODELS — no LOW.\n # ECO routes ``recon`` (and other LOW roles) to Tier.LOW, so the\n # LOW chain resolves to []. The factory must refuse to construct.\n creds = Credentials(methods=[AuthMethod.MINIMAX_API])\n with pytest.raises(ValueError) as exc_info:\n LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n msg = str(exc_info.value)\n # Names the empty tier so the operator knows what to fix.\n assert \"low\" in msg.lower()\n # Echoes the configured credentials so the operator can audit\n # what was actually detected (vs. what they think they set).\n assert AuthMethod.MINIMAX_API.value in msg\n\n def test_test_profile_empty_high_chain_raises(self) -> None:\n # MINIMAX_API covers HIGH+MID but not LOW; TEST profile forces every\n # role to LOW → empty chain across the board.\n creds = Credentials(methods=[AuthMethod.MINIMAX_API])\n with pytest.raises(ValueError, match=\"(?i)low\"):\n LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.TEST)\n\n def test_valid_inventory_still_constructs(self) -> None:\n # OPENAI_API covers all three tiers — no empty chain, no regression.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n factory = LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n assert factory.get_model(\"recon\").model_name == \"openai/gpt-5-nano\"\n assert factory.get_model(\"decepticon\").model_name == \"openai/gpt-5.5\"\n\n def test_explicit_mapping_bypasses_validation(self) -> None:\n # When the caller supplies a mapping directly, they own its shape —\n # the credentials-based validation does not apply (existing tests\n # build empty/partial mappings explicitly).\n factory = LLMFactory(self.proxy, mapping=LLMModelMapping())\n assert factory.proxy_url == \"http://localhost:4000\"\n\n\nclass TestLLMFactoryHealthCheck:\n def test_health_check_returns_false_when_no_proxy(self):\n proxy = ProxyConfig(url=\"http://localhost:19999\")\n factory = LLMFactory(proxy, mapping=LLMModelMapping())\n assert asyncio.run(factory.health_check()) is False\n\n\nclass TestResolveCredentials:\n def test_real_keys_only(self, monkeypatch):\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"your-openai-key-here\") # placeholder\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_API]\n\n def test_oauth_only(self, monkeypatch, tmp_path):\n # OAuth detection requires the credential FILE alongside the\n # boolean — point Claude Code at a temp credentials file so the\n # test runs deterministically regardless of host state.\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_OAUTH]\n\n def test_oauth_flag_without_credential_file_is_dropped(self, monkeypatch, tmp_path):\n \"\"\"Stale ``DECEPTICON_AUTH_CLAUDE_CODE=true`` after ``codex logout``\n (or after the user deleted ``~/.claude/.credentials.json``) must\n not place the OAuth method into the chain — otherwise every\n request 401s before falling back to the next provider.\n \"\"\"\n # Point both the primary and legacy fallback paths at tmp_path so\n # any ``~/.claude/.credentials.json`` or\n # ``~/.config/anthropic/q/tokens.json`` on the dev host doesn't\n # accidentally satisfy the file-presence check.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n missing = tmp_path / \"missing.json\" # never created\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n creds = _resolve_credentials()\n assert AuthMethod.ANTHROPIC_OAUTH not in creds.methods\n\n def test_oauth_plus_api_priority_default(self, monkeypatch, tmp_path):\n # Default priority is anthropic_oauth > anthropic_api > openai_api ...\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n ]\n\n def test_explicit_priority_override(self, monkeypatch):\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"openai_api,anthropic_api\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n monkeypatch.delenv(\"GEMINI_API_KEY\", raising=False)\n monkeypatch.delenv(\"MINIMAX_API_KEY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OPENAI_API, AuthMethod.ANTHROPIC_API]\n\n def test_placeholder_falls_back_to_all_api_methods(self, monkeypatch):\n \"\"\"When every detected method is a placeholder/missing, the resolver\n falls back to the all-API-methods inventory so module-level agent\n constructors stay importable in CI / dev shells without keys.\"\"\"\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"your-anthropic-key-here\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n ]\n\n def test_explicit_priority_no_creds_logs_error(self, monkeypatch, tmp_path, caplog):\n \"\"\"An explicit ``DECEPTICON_AUTH_PRIORITY`` whose every listed\n method fails detection (typical of a broken OAuth credentials\n mount — e.g. ``CLAUDE_CREDENTIALS_VOLUME`` unset and the\n compose file bound ``/dev/null`` into the container) must\n surface the root cause at ERROR level.\n\n Otherwise the silent fallback to ``all_api_methods()`` runs\n through nine unrelated providers and the operator only sees a\n downstream 401-cascade — confusingly masked as a 429 once the\n routed-to provider (e.g. NVIDIA NIM) cools down. Backward\n compat is preserved: the resolver still returns\n ``all_api_methods()`` so module imports remain green; the\n real model call surfaces a separate, actionable error via\n ``_reraise_with_actionable_message``.\n \"\"\"\n # Point the OAuth credential path at a file we never create so\n # detection fails the same way ``/dev/null`` does at runtime.\n missing = tmp_path / \"missing.json\"\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"anthropic_oauth\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n # ``decepticon_core.utils.logging`` sets the parent decepticon logger\n # to ``propagate=False`` so library output doesn't double up in\n # apps that own their root handler. caplog hooks the root logger,\n # so without re-enabling propagation our ERROR record never\n # reaches pytest's capture buffer. Re-enable for the duration of\n # the call and let monkeypatch restore on teardown.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.ERROR):\n creds = _resolve_credentials()\n\n # Backward compat: still returns the all-API-methods fallback.\n assert creds.methods == Credentials.all_api_methods().methods\n\n # ERROR diagnostic must mention the env var name so an operator\n # scanning ``decepticon logs langgraph`` knows where to look.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert any(\"DECEPTICON_AUTH_PRIORITY\" in r.getMessage() for r in error_records), (\n f\"No ERROR log mentioned DECEPTICON_AUTH_PRIORITY. \"\n f\"Got: {[r.getMessage() for r in error_records]}\"\n )\n\n def test_implicit_priority_no_creds_stays_info_level(self, monkeypatch, caplog):\n \"\"\"Implicit priority (no DECEPTICON_AUTH_PRIORITY set) with no\n detectable credentials is the import-friendly CI/test path:\n we keep the existing INFO log and ``all_api_methods()`` return\n so module imports work without keys present. Only **explicit**\n priority with no matches gets escalated to ERROR.\n \"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n\n # Match the explicit-priority test's pattern so caplog sees the\n # records — the decepticon parent logger defaults to\n # ``propagate=False`` per ``decepticon_core.utils.logging``.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.DEBUG):\n creds = _resolve_credentials()\n\n assert creds.methods == Credentials.all_api_methods().methods\n # No ERROR records — this case stays informational.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert not error_records, (\n f\"Unexpected ERROR logs in implicit-priority path: \"\n f\"{[r.getMessage() for r in error_records]}\"\n )\n\n def test_ollama_local_only_returns_ollama_chain(self, monkeypatch):\n \"\"\"Issue #106: a user with only OLLAMA_API_BASE / OLLAMA_MODEL set\n (no API keys, no OAuth) must get a chain of one — Ollama only.\n Falling back to all-API-methods would produce 401 errors on every\n provider the user doesn't have.\"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL]\n\n def test_explicit_priority_with_ollama_local(self, monkeypatch):\n \"\"\"User opts into Ollama via explicit priority — the resolver\n recognizes it as configured when OLLAMA_API_BASE is set.\"\"\"\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"ollama_local,anthropic_api\")\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\n# ── Temperature handling (issue #107) ───────────────────────────────────\n\n\nclass TestTemperatureDrop:\n \"\"\"Claude Opus 4.7 rejects ``temperature`` regardless of route. The\n factory must drop it on every Opus 4 surface (anthropic/, auth/,\n openrouter/anthropic/) and keep it for everyone else.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _model_drops_temperature\n\n self._drops = _model_drops_temperature\n\n def test_anthropic_opus_drops_temperature(self):\n assert self._drops(\"anthropic/claude-opus-4-7\") is True\n\n def test_oauth_opus_drops_temperature(self):\n assert self._drops(\"auth/claude-opus-4-7\") is True\n\n def test_openrouter_opus_drops_temperature(self):\n assert self._drops(\"openrouter/anthropic/claude-opus-4-7\") is True\n\n def test_sonnet_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-sonnet-4-6\") is False\n\n def test_haiku_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-haiku-4-5\") is False\n\n def test_openai_keeps_temperature(self):\n assert self._drops(\"openai/gpt-5.5\") is False\n\n def test_ollama_keeps_temperature(self):\n assert self._drops(\"ollama_chat/qwen3-coder:30b\") is False\n\n\n# ── Actionable error translation (issue #107 + community feedback) ──────\n\n\nclass TestActionableErrorTranslation:\n \"\"\"The OSS user complaint: every upstream failure surfaces as 'An\n internal error occurred', stripping the message that would tell them\n what to fix. Each branch below verifies one class of error gets\n rewritten with a remediation hint and the model id that hit the\n failure.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _reraise_with_actionable_message\n\n self._translate = _reraise_with_actionable_message\n\n def test_no_fallback_model_group_branch(self):\n exc = Exception(\n \"litellm.BadRequestError: ... No fallback model group found \"\n \"for original model_group=anthropic/claude-opus-4-7.\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"no provider fallback\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_400_bad_request_branch(self):\n # openai.BadRequestError carries 'Error code: 400' in repr.\n exc = Exception(\"Error code: 400 - {'error': {'message': 'temperature is deprecated'}}\")\n type(exc).__name__ # noqa: B018 — sanity, ensures Exception default name\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n assert \"rejected the request (400)\" in str(info.value)\n\n def test_401_authentication_branch(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\"Error code: 401 - invalid_api_key\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"credentials (401)\" in msg\n assert \"decepticon onboard --reset\" in msg\n\n def test_429_ratelimit_branch(self):\n exc = type(\"RateLimitError\", (Exception,), {})(\"Error code: 429\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"rate limit (429)\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_404_notfound_with_ollama_hint(self):\n exc = type(\"NotFoundError\", (Exception,), {})(\"Error code: 404 - model not found\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"ollama_chat/nonexistent\")\n msg = str(info.value)\n assert \"404\" in msg\n assert \"OLLAMA_MODEL\" in msg\n\n def test_unmatched_error_passes_through(self):\n # Anything we don't recognize must NOT raise — the caller's\n # ``raise`` follows and re-raises the original exception with\n # full traceback.\n exc = ValueError(\"something completely unrelated\")\n # Should not raise from the helper.\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n\n def test_401_redacts_bearer_token_keeps_guidance(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\n \"Error code: 401 - {'error': 'invalid_api_key'} \"\n \"Authorization: Bearer sk-ant-SECRET123FAKEPLACEHOLDERTOKEN\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"sk-ant-SECRET123FAKEPLACEHOLDERTOKEN\" not in msg\n assert \"SECRET123FAKEPLACEHOLDERTOKEN\" not in msg\n assert \"[REDACTED]\" in msg\n assert \"credentials (401)\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n\n def test_400_redacts_api_key_kwarg_keeps_guidance(self):\n exc = Exception(\n \"Error code: 400 - bad request from provider with api_key=sk-SECRETfakeplaceholdervalue99\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"sk-SECRETfakeplaceholdervalue99\" not in msg\n assert \"SECRETfakeplaceholdervalue99\" not in msg\n assert \"[REDACTED]\" in msg\n assert \"rejected the request (400)\" in msg\n\n def test_redact_secrets_preserves_nonsecret_text(self):\n from decepticon.llm.factory import _redact_secrets\n\n text = \"No fallback model group found for model_group=anthropic/claude-opus-4-7.\"\n assert _redact_secrets(text) == text\n\n\n# ── DeepSeek V4 Pro reasoning_content passthrough ────────────────────────\n\n\nclass TestDeepSeekReasoningContent:\n \"\"\"Verify reasoning_content survives both the non-streaming and streaming\n paths so it can be sent back on subsequent API turns.\"\"\"\n\n def test_create_chat_result_extracts_reasoning_content(self):\n \"\"\"Non-streaming: _create_chat_result captures reasoning_content\n from the response dict's choice message.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Simulate the OpenAI response dict with reasoning_content\n response_dict = {\n \"choices\": [\n {\n \"message\": {\n \"role\": \"assistant\",\n \"content\": \"Four\",\n \"reasoning_content\": \"I think therefore I am\",\n },\n \"finish_reason\": \"stop\",\n }\n ],\n \"usage\": {},\n }\n\n # Mock the parent's _create_chat_result to return a ChatResult without reasoning_content\n msg = AIMessage(content=\"Four\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert (\n result.generations[0].message.additional_kwargs[\"reasoning_content\"]\n == \"I think therefore I am\"\n )\n\n def test_create_chat_result_skips_when_absent(self):\n \"\"\"Non-streaming: no crash when reasoning_content is missing.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n response_dict = {\n \"choices\": [\n {\"message\": {\"role\": \"assistant\", \"content\": \"hi\"}, \"finish_reason\": \"stop\"}\n ],\n \"usage\": {},\n }\n\n msg = AIMessage(content=\"hi\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert \"reasoning_content\" not in result.generations[0].message.additional_kwargs\n\n def test_convert_chunk_injects_reasoning_content(self):\n \"\"\"Streaming: _convert_chunk_to_generation_chunk captures\n reasoning_content from the raw delta and injects it into\n AIMessageChunk.additional_kwargs.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Build a fake raw SSE chunk dict with reasoning_content in the delta\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Let me think...\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n # The parent's _convert_chunk_to_generation_chunk builds the\n # ChatGenerationChunk but ignores reasoning_content. We mock it\n # to return a chunk without reasoning_content, then verify our\n # override injects it.\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n # Bind the real method to our mock instance\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert result.message.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n\n def test_convert_chunk_no_reasoning_leaves_kwargs_clean(self):\n \"\"\"Streaming: when delta has no reasoning_content, additional_kwargs\n is not polluted.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\"role\": \"assistant\", \"content\": \"hi\"},\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_reasoning_content_accumulates_across_chunks(self):\n \"\"\"Streaming: reasoning_content from multiple chunks concatenates\n via AIMessageChunk.__add__ (merge_dicts).\"\"\"\n from langchain_core.messages import AIMessageChunk\n\n c1 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"Let me \"})\n c2 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"think...\"})\n c3 = AIMessageChunk(content=\"Hello!\", additional_kwargs={})\n\n merged = c1 + c2 + c3\n assert merged.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n assert merged.content == \"Hello!\"\n\n def test_get_request_payload_injects_reasoning_content(self):\n \"\"\"Outbound: _get_request_payload injects reasoning_content from\n AIMessage.additional_kwargs into serialized assistant message dicts.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"hi\"),\n AIMessage(\n content=\"hello\",\n additional_kwargs={\"reasoning_content\": \"thinking...\"},\n ),\n ]\n\n # Mock super()._get_request_payload to return a payload without reasoning_content\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"hi\"},\n {\"role\": \"assistant\", \"content\": \"hello\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thinking...\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert payload[\"extra_body\"][\"thinking\"] == {\"type\": \"enabled\"}\n assert payload[\"reasoning_effort\"] == \"high\"\n\n def test_convert_chunk_empty_reasoning_content_ignored(self):\n \"\"\"Streaming: empty string reasoning_content in delta is ignored.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"hi\",\n \"reasoning_content\": \"\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n # Empty string is falsy — should not pollute additional_kwargs\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_none_delta_no_crash(self):\n \"\"\"Streaming: None delta in choices does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": None,\n \"finish_reason\": \"stop\",\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_empty_choices_no_crash(self):\n \"\"\"Streaming: empty choices array does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"choices\": []}\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_parent_returns_none(self):\n \"\"\"Streaming: when parent returns None, we return None too.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"type\": \"content.delta\"} # parent returns None for these\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=None,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is None\n\n def test_convert_chunk_beta_stream_format(self):\n \"\"\"Streaming: handles beta.chat.completions.stream nested chunk format.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Some LangChain versions nest under \"chunk\" key\n raw_chunk = {\n \"chunk\": {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Thinking via beta format...\",\n },\n \"finish_reason\": None,\n }\n ],\n },\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert (\n result.message.additional_kwargs[\"reasoning_content\"] == \"Thinking via beta format...\"\n )\n\n def test_get_request_payload_multiple_assistant_messages(self):\n \"\"\"Outbound: each assistant message gets its own reasoning_content.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"q1\"),\n AIMessage(content=\"a1\", additional_kwargs={\"reasoning_content\": \"thought1\"}),\n HumanMessage(content=\"q2\"),\n AIMessage(content=\"a2\", additional_kwargs={\"reasoning_content\": \"thought2\"}),\n ]\n\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"q1\"},\n {\"role\": \"assistant\", \"content\": \"a1\"},\n {\"role\": \"user\", \"content\": \"q2\"},\n {\"role\": \"assistant\", \"content\": \"a2\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thought1\"\n assert payload[\"messages\"][3][\"reasoning_content\"] == \"thought2\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert \"reasoning_content\" not in payload[\"messages\"][2]\n\n def test_model_detection(self):\n \"\"\"Factory routes DeepSeek V4 (pro + flash) and legacy reasoner.\n\n Per DeepSeek's API docs ``deepseek-reasoner`` is the deprecated\n alias for ``deepseek-v4-flash`` thinking mode, so v4-flash also\n returns ``reasoning_content`` and the API rejects subsequent\n tool turns when the field is omitted. Closes #201, #220.\n \"\"\"\n from decepticon.llm.factory import _model_is_deepseek_thinking\n\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-pro\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-flash\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-reasoner\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-chat\") is False\n assert _model_is_deepseek_thinking(\"openai/gpt-5.5\") is False\n\n\n# ── LLAMACPP_LOCAL credential detection (issue #151) ────────────────────\n\n\n_LLAMACPP_RELATED_ENV = (\n \"LLAMACPP_API_BASE\",\n \"LLAMACPP_MODEL\",\n \"LLAMACPP_API_KEY\",\n)\n\n\ndef _scrub_other_credentials(monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"Delete any env that would be detected as another credential.\n\n The credential-detection tests below need to assert \"only LLAMACPP\n is detected\"; without this scrub, a developer's exported\n ``ANTHROPIC_API_KEY`` would creep in and the assertion would fail\n locally only.\n \"\"\"\n for var in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"GROQ_API_KEY\",\n \"TOGETHER_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"COHERE_API_KEY\",\n \"MOONSHOT_API_KEY\",\n \"ZAI_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"GITHUB_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n ):\n monkeypatch.delenv(var, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n\nclass TestLlamacppLocalConfigured:\n \"\"\"``_llamacpp_local_configured`` is the gate for adding\n ``LLAMACPP_LOCAL`` to the credentials chain. Either env var being\n set is enough — neither requires the other, mirroring the\n LM Studio / Ollama detection contract.\n \"\"\"\n\n def test_returns_false_when_neither_env_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n assert _llamacpp_local_configured() is False\n\n def test_returns_true_when_only_base_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n assert _llamacpp_local_configured() is True\n\n def test_returns_true_when_only_model_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n assert _llamacpp_local_configured() is True\n\n def test_whitespace_does_not_count_as_set(self, monkeypatch):\n \"\"\"Stray ``LLAMACPP_API_BASE=`` lines in .env must not silently\n opt the user in — same fail-soft as the Ollama detector.\"\"\"\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \" \")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"\")\n assert _llamacpp_local_configured() is False\n\n\nclass TestResolveCredentialsForLlamacpp:\n \"\"\"End-to-end check that ``_resolve_credentials`` picks up the\n user's llama.cpp config — both the explicit-priority path and the\n \"only LLAMACPP_* detected\" auto-fallback at the bottom of\n ``_resolve_credentials``.\n \"\"\"\n\n def test_only_llamacpp_detected_returns_llamacpp_only_chain(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL]\n\n def test_priority_list_with_llamacpp_picks_it_up(self, monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"User wrote an explicit DECEPTICON_AUTH_PRIORITY listing\n llamacpp_local — the resolver must respect that ordering.\"\"\"\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen-coder\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"llamacpp_local,anthropic_api\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\nclass TestLLMTimeout:\n \"\"\"Whole-coroutine LLM request timeout (DECEPTICON_LLM_TIMEOUT_SECONDS).\"\"\"\n\n @pytest.fixture(autouse=True)\n def _isolate_timeout_env(self, monkeypatch: pytest.MonkeyPatch) -> None:\n monkeypatch.delenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_LLM__TIMEOUT\", raising=False)\n\n def test_call_with_timeout_raises_typed_exception(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> str:\n await asyncio.sleep(10)\n return \"done\"\n\n with pytest.raises(LLMTimeoutError, match=\"timed out after 0.01 seconds\"):\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n\n def test_call_with_timeout_preserves_original_timeout_as_cause(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> None:\n await asyncio.sleep(10)\n\n try:\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n except LLMTimeoutError as exc:\n assert isinstance(exc.__cause__, asyncio.TimeoutError)\n else:\n raise AssertionError(\"expected LLMTimeoutError\")\n\n def test_call_with_timeout_returns_successful_result(self) -> None:\n from decepticon.llm.factory import call_with_timeout\n\n async def fast_call() -> str:\n await asyncio.sleep(0)\n return \"ok\"\n\n assert asyncio.run(call_with_timeout(fast_call(), 1)) == \"ok\"\n\n def test_call_with_timeout_propagates_non_timeout_exceptions(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def failing_call() -> None:\n raise RuntimeError(\"upstream 500\")\n\n with pytest.raises(RuntimeError, match=\"upstream 500\"):\n asyncio.run(call_with_timeout(failing_call(), 1))\n # And not as a timeout error\n try:\n asyncio.run(call_with_timeout(failing_call(), 1))\n except LLMTimeoutError:\n raise AssertionError(\"non-timeout exception was retyped\")\n except RuntimeError:\n pass\n\n def test_env_override_takes_effect(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"3\")\n assert _resolve_llm_timeout_seconds() == 3.0\n\n def test_default_timeout_is_600_when_env_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.delenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", raising=False)\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_blank_env_falls_back_to_default(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \" \")\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_invalid_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"not-a-number\")\n with pytest.raises(ValueError):\n _resolve_llm_timeout_seconds()\n\n def test_non_positive_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"0\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"-5\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n def test_pydantic_nested_alias_takes_effect(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"900\")\n assert _resolve_llm_timeout_seconds() == 900.0\n\n def test_explicit_env_wins_over_pydantic_alias(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"120\")\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"900\")\n assert _resolve_llm_timeout_seconds() == 120.0\n\n def test_alias_invalid_value_attributes_error_to_alias(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"nonsense\")\n with pytest.raises(ValueError, match=\"DECEPTICON_LLM__TIMEOUT\"):\n _resolve_llm_timeout_seconds()\n\n def test_alias_non_positive_attributes_error_to_alias(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"0\")\n with pytest.raises(ValueError, match=\"DECEPTICON_LLM__TIMEOUT.*greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n def test_ainvoke_resolves_timeout_before_creating_request_coroutine(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n \"\"\"A misconfigured timeout env must fail loudly *without* creating\n (and leaking un-awaited) the upstream request coroutine. Regression:\n the timeout was previously resolved as the second argument to\n ``call_with_timeout``, so ``super().ainvoke(...)`` was already\n evaluated — a ValueError then left that coroutine never awaited.\"\"\"\n from langchain_openai import ChatOpenAI\n from pydantic import SecretStr\n\n from decepticon.llm.factory import _ProxiedChatOpenAI\n\n called = False\n\n async def _tracking_ainvoke(self, *args, **kwargs): # noqa: ANN001\n nonlocal called\n called = True\n return \"should-not-happen\"\n\n monkeypatch.setattr(ChatOpenAI, \"ainvoke\", _tracking_ainvoke)\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"-5\")\n\n model = _ProxiedChatOpenAI(\n model=\"openai/gpt-5.5\",\n base_url=\"http://localhost:4000\",\n api_key=SecretStr(\"test-key\"),\n )\n with pytest.raises(ValueError, match=\"greater than 0\"):\n asyncio.run(model.ainvoke(\"hi\"))\n assert called is False, \"upstream request coroutine must not be created on bad timeout\"\n"}, "2bc249a70de9ab51c0395d0f0aff7aa165f492f9": {"t": 1785755383.1968253, "n": 63899, "h": "e457ce1d43975348", "c": "\"\"\"Unit tests for decepticon.llm.factory.\"\"\"\n\nimport asyncio\nimport logging\n\nimport pytest\n\nfrom decepticon.llm.factory import (\n LLM_MAX_TOKENS_ENV,\n LLMFactory,\n _is_real_key,\n _llamacpp_local_configured,\n _method_is_configured,\n _model_max_output_tokens,\n _oauth_credentials_present,\n _oauth_env_credentials_present,\n _resolve_credentials,\n _resolve_max_tokens,\n)\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\n\n\nclass TestIsRealKey:\n \"\"\"Vendor-aware API key validation.\n\n The launcher writes ``your-…-key-here`` placeholders into .env so the\n user can later swap in a real key. The factory needs to reject those\n plus any obvious junk (short strings, ``placeholder``/``not-used``\n markers, vendor-prefix mismatches) so the credentials inventory\n reflects what actually works at request time.\n \"\"\"\n\n def test_rejects_empty_and_placeholder_template(self) -> None:\n assert _is_real_key(\"\") is False\n assert _is_real_key(\" \") is False\n assert _is_real_key(\"your-anthropic-key-here\") is False\n assert _is_real_key(\"YOUR-OPENAI-KEY-HERE\") is False # case-insensitive\n\n def test_rejects_short_strings(self) -> None:\n # Under 24 chars — every vendor-issued key exceeds this.\n assert _is_real_key(\"sk-tooshort\") is False\n\n def test_rejects_placeholder_tokens_in_value(self) -> None:\n long_enough = \"x\" * 30\n for token in (\"placeholder\", \"not-used\", \"dummy\", \"fake\", \"example\"):\n assert _is_real_key(f\"sk-{token}-{long_enough}\") is False, token\n\n def test_accepts_realistic_keys_without_method(self) -> None:\n # Without method context, prefix check is skipped.\n assert _is_real_key(\"sk-ant-api03-realtokenfortestingauthrouting12345\") is True\n assert _is_real_key(\"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\") is True\n\n def test_rejects_wrong_vendor_prefix(self) -> None:\n # An OpenAI-shaped key in the Anthropic slot must be caught.\n openai_key = \"sk-proj-realopenaitokenfortestingauthrouting12345\"\n assert _is_real_key(openai_key, AuthMethod.ANTHROPIC_API) is False\n # …and vice versa.\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.GOOGLE_API) is False\n\n def test_accepts_correct_vendor_prefix(self) -> None:\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.ANTHROPIC_API) is True\n google_key = \"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\"\n assert _is_real_key(google_key, AuthMethod.GOOGLE_API) is True\n\n\nclass TestOAuthCredentialsPresent:\n \"\"\"OAuth detection requires the credential file alongside the boolean.\n\n Without the file check, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place OAuth in\n every fallback chain and generate one 401 per request.\n \"\"\"\n\n def test_returns_false_when_file_missing(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(tmp_path / \"absent.json\"))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_when_file_is_empty(self, monkeypatch, tmp_path) -> None:\n # ``/dev/null``-style mounts read as empty — must fail closed.\n empty = tmp_path / \"empty.json\"\n empty.write_text(\"\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(empty))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_on_invalid_json(self, monkeypatch, tmp_path) -> None:\n bad = tmp_path / \"bad.json\"\n bad.write_text(\"{not-json\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(bad))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_true_when_file_is_well_formed(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"credentials.json\"\n good.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeef\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is True\n\n def test_codex_path_via_env_override(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"auth.json\"\n good.write_text('{\"tokens\": {\"access_token\": \"ABC\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CODEX_AUTH_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.OPENAI_OAUTH) is True\n\n\nclass TestOAuthEnvCredentials:\n \"\"\"B12: env-backed OAuth creds count as configured, not just on-disk files.\n Before the fix, ``_method_is_configured`` required a credential *file* even\n though the LiteLLM handlers also accept env-var tokens (GEMINI_ACCESS_TOKEN,\n GROK_SESSION_TOKEN, COPILOT_REFRESH_TOKEN, …). Env-only setups were silently\n dropped from the fallback chain.\n \"\"\"\n\n def test_env_present_returns_true_for_each_method(self, monkeypatch) -> None:\n cases = {\n AuthMethod.GOOGLE_OAUTH: \"GEMINI_ACCESS_TOKEN\",\n AuthMethod.COPILOT_OAUTH: \"COPILOT_REFRESH_TOKEN\",\n AuthMethod.GROK_OAUTH: \"GROK_SESSION_TOKEN\",\n AuthMethod.PERPLEXITY_OAUTH: \"PERPLEXITY_SESSION_TOKEN\",\n AuthMethod.ANTHROPIC_OAUTH: \"ANTHROPIC_OAUTH_TOKEN\",\n }\n for method, env_var in cases.items():\n monkeypatch.setenv(env_var, \"tok-from-env\")\n assert _oauth_env_credentials_present(method) is True\n monkeypatch.delenv(env_var, raising=False)\n\n def test_env_only_method_is_configured_without_file(self, monkeypatch, tmp_path) -> None:\n # Flag true + env token, but the credential file is absent.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.setenv(\"DECEPTICON_AUTH_GEMINI\", \"true\")\n monkeypatch.setenv(\"GEMINI_ACCESS_TOKEN\", \"ya29.env-token\")\n assert _oauth_credentials_present(AuthMethod.GOOGLE_OAUTH) is False\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is True\n\n def test_method_not_configured_without_file_or_env(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.setenv(\"DECEPTICON_AUTH_GEMINI\", \"true\")\n monkeypatch.delenv(\"GEMINI_ACCESS_TOKEN\", raising=False)\n monkeypatch.delenv(\"GEMINI_SESSION_COOKIES\", raising=False)\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is False\n\n def test_env_creds_still_require_intent_flag(self, monkeypatch, tmp_path) -> None:\n # Env token present but the boolean intent flag is off → not configured.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.delenv(\"DECEPTICON_AUTH_GEMINI\", raising=False)\n monkeypatch.setenv(\"GEMINI_ACCESS_TOKEN\", \"ya29.env-token\")\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is False\n\n def test_codex_has_no_env_token_override(self, monkeypatch) -> None:\n # Codex authenticates only from its file; no env-token override exists.\n monkeypatch.setenv(\"CODEX_ACCESS_TOKEN\", \"should-not-count\")\n assert _oauth_env_credentials_present(AuthMethod.OPENAI_OAUTH) is False\n\n\nclass TestMaxTokensResolution:\n \"\"\"Per-model output-token ceiling (fixes write_file truncation).\"\"\"\n\n def test_opus_and_sonnet_get_128k(self) -> None:\n assert _model_max_output_tokens(\"anthropic/claude-opus-4-8\") == 128000\n assert _model_max_output_tokens(\"auth/claude-opus-4-8\") == 128000\n assert _model_max_output_tokens(\"openrouter/anthropic/claude-sonnet-4-6\") == 128000\n\n def test_haiku_gets_64k(self) -> None:\n assert _model_max_output_tokens(\"anthropic/claude-haiku-4-5\") == 64000\n\n def test_unknown_model_falls_back_to_safe_default(self) -> None:\n assert _model_max_output_tokens(\"openai/gpt-5-nano\") == 64000\n\n def test_env_override_wins(self, monkeypatch) -> None:\n monkeypatch.setenv(LLM_MAX_TOKENS_ENV, \"8192\")\n # Override wins even for a model whose own ceiling is higher.\n assert _resolve_max_tokens(\"anthropic/claude-opus-4-8\") == 8192\n\n def test_invalid_env_falls_back_to_per_model(self, monkeypatch) -> None:\n monkeypatch.setenv(LLM_MAX_TOKENS_ENV, \"not-a-number\")\n assert _resolve_max_tokens(\"anthropic/claude-opus-4-8\") == 128000\n\n def test_no_env_uses_per_model(self, monkeypatch) -> None:\n monkeypatch.delenv(LLM_MAX_TOKENS_ENV, raising=False)\n assert _resolve_max_tokens(\"anthropic/claude-haiku-4-5\") == 64000\n\n\nclass TestLLMFactory:\n def setup_method(self):\n self.proxy = ProxyConfig(url=\"http://localhost:4000\", api_key=\"test-key\")\n # Build an explicit mapping so the test doesn't depend on env vars.\n creds = Credentials.all_api_methods()\n self.mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n self.factory = LLMFactory(self.proxy, self.mapping)\n\n def test_factory_initializes(self):\n assert self.factory.proxy_url == \"http://localhost:4000\"\n\n def test_get_model_returns_chat_model(self):\n model = self.factory.get_model(\"recon\")\n assert model is not None\n assert model.model_name == \"anthropic/claude-haiku-4-5\"\n\n def test_get_model_caches_instances(self):\n m1 = self.factory.get_model(\"recon\")\n m2 = self.factory.get_model(\"recon\")\n assert m1 is m2\n\n def test_get_model_different_roles_different_models(self):\n recon = self.factory.get_model(\"recon\")\n decepticon = self.factory.get_model(\"decepticon\")\n assert recon is not decepticon\n assert recon.model_name != decepticon.model_name\n\n def test_get_model_unknown_role_raises(self):\n with pytest.raises(KeyError, match=\"No model assignment\"):\n self.factory.get_model(\"nonexistent\")\n\n def test_router_accessible(self):\n assert self.factory.router is not None\n\n def test_get_fallback_models_full_chain(self):\n models = self.factory.get_fallback_models(\"recon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5-nano\",\n \"gemini/gemini-2.5-flash-lite\",\n \"deepseek/deepseek-v4-flash\",\n \"openrouter/anthropic/claude-haiku-4-5\",\n \"nvidia_nim/meta/llama-3.2-3b-instruct\",\n ]\n\n def test_get_fallback_models_high_tier_includes_all_methods(self):\n models = self.factory.get_fallback_models(\"decepticon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5.5\",\n \"gemini/gemini-2.5-pro\",\n \"minimax/MiniMax-M3\",\n \"deepseek/deepseek-v4-pro\",\n \"xai/grok-4.3\",\n \"mistral/mistral-large-latest\",\n \"openrouter/anthropic/claude-opus-4-8\",\n \"nvidia_nim/meta/llama-3.3-70b-instruct\",\n ]\n\n def test_get_fallback_models_without_fallback(self):\n # Single-credential mapping → no fallback.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n factory = LLMFactory(self.proxy, mapping)\n assert factory.get_fallback_models(\"recon\") == []\n\n def test_explicit_credentials_param(self):\n # Constructor accepts a Credentials object instead of a full mapping.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n factory = LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n assert factory.get_model(\"decepticon\").model_name == \"openai/gpt-5.5\"\n\n\nclass TestLLMFactoryEmptyChainValidation:\n \"\"\"A credentials inventory whose AuthMethods lack a (method, tier) entry\n for some required tier resolves to an EMPTY model chain at that tier.\n ``LLMModelMapping.from_credentials_and_profile`` silently skips such\n roles, so the failure surfaces much later — either as a confusing\n ``KeyError: No model assignment for role: ...`` at ``get_model`` time\n or, when a caller routes through middleware, as a primary-less\n ``ModelFallbackMiddleware([])``. Validate at construction so the\n operator gets one actionable error naming the empty tier(s) and the\n configured credentials inventory.\"\"\"\n\n def setup_method(self) -> None:\n self.proxy = ProxyConfig(url=\"http://localhost:4000\", api_key=\"test-key\")\n\n def test_empty_tier_raises_at_init_with_inventory_listed(self) -> None:\n # MINIMAX_API has only HIGH+MID entries in METHOD_MODELS — no LOW.\n # ECO routes ``recon`` (and other LOW roles) to Tier.LOW, so the\n # LOW chain resolves to []. The factory must refuse to construct.\n creds = Credentials(methods=[AuthMethod.MINIMAX_API])\n with pytest.raises(ValueError) as exc_info:\n LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n msg = str(exc_info.value)\n # Names the empty tier so the operator knows what to fix.\n assert \"low\" in msg.lower()\n # Echoes the configured credentials so the operator can audit\n # what was actually detected (vs. what they think they set).\n assert AuthMethod.MINIMAX_API.value in msg\n\n def test_test_profile_empty_high_chain_raises(self) -> None:\n # MINIMAX_API covers HIGH+MID but not LOW; TEST profile forces every\n # role to LOW → empty chain across the board.\n creds = Credentials(methods=[AuthMethod.MINIMAX_API])\n with pytest.raises(ValueError, match=\"(?i)low\"):\n LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.TEST)\n\n def test_valid_inventory_still_constructs(self) -> None:\n # OPENAI_API covers all three tiers — no empty chain, no regression.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n factory = LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n assert factory.get_model(\"recon\").model_name == \"openai/gpt-5-nano\"\n assert factory.get_model(\"decepticon\").model_name == \"openai/gpt-5.5\"\n\n def test_explicit_mapping_bypasses_validation(self) -> None:\n # When the caller supplies a mapping directly, they own its shape —\n # the credentials-based validation does not apply (existing tests\n # build empty/partial mappings explicitly).\n factory = LLMFactory(self.proxy, mapping=LLMModelMapping())\n assert factory.proxy_url == \"http://localhost:4000\"\n\n\nclass TestLLMFactoryHealthCheck:\n def test_health_check_returns_false_when_no_proxy(self):\n proxy = ProxyConfig(url=\"http://localhost:19999\")\n factory = LLMFactory(proxy, mapping=LLMModelMapping())\n assert asyncio.run(factory.health_check()) is False\n\n\nclass TestResolveCredentials:\n def test_real_keys_only(self, monkeypatch):\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"your-openai-key-here\") # placeholder\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_API]\n\n def test_oauth_only(self, monkeypatch, tmp_path):\n # OAuth detection requires the credential FILE alongside the\n # boolean — point Claude Code at a temp credentials file so the\n # test runs deterministically regardless of host state.\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_OAUTH]\n\n def test_oauth_flag_without_credential_file_is_dropped(self, monkeypatch, tmp_path):\n \"\"\"Stale ``DECEPTICON_AUTH_CLAUDE_CODE=true`` after ``codex logout``\n (or after the user deleted ``~/.claude/.credentials.json``) must\n not place the OAuth method into the chain — otherwise every\n request 401s before falling back to the next provider.\n \"\"\"\n # Point both the primary and legacy fallback paths at tmp_path so\n # any ``~/.claude/.credentials.json`` or\n # ``~/.config/anthropic/q/tokens.json`` on the dev host doesn't\n # accidentally satisfy the file-presence check.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n missing = tmp_path / \"missing.json\" # never created\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n creds = _resolve_credentials()\n assert AuthMethod.ANTHROPIC_OAUTH not in creds.methods\n\n def test_oauth_plus_api_priority_default(self, monkeypatch, tmp_path):\n # Default priority is anthropic_oauth > anthropic_api > openai_api ...\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n ]\n\n def test_explicit_priority_override(self, monkeypatch):\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"openai_api,anthropic_api\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n monkeypatch.delenv(\"GEMINI_API_KEY\", raising=False)\n monkeypatch.delenv(\"MINIMAX_API_KEY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OPENAI_API, AuthMethod.ANTHROPIC_API]\n\n def test_placeholder_falls_back_to_all_api_methods(self, monkeypatch):\n \"\"\"When every detected method is a placeholder/missing, the resolver\n falls back to the all-API-methods inventory so module-level agent\n constructors stay importable in CI / dev shells without keys.\"\"\"\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"your-anthropic-key-here\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n ]\n\n def test_explicit_priority_no_creds_logs_error(self, monkeypatch, tmp_path, caplog):\n \"\"\"An explicit ``DECEPTICON_AUTH_PRIORITY`` whose every listed\n method fails detection (typical of a broken OAuth credentials\n mount — e.g. ``CLAUDE_CREDENTIALS_VOLUME`` unset and the\n compose file bound ``/dev/null`` into the container) must\n surface the root cause at ERROR level.\n\n Otherwise the silent fallback to ``all_api_methods()`` runs\n through nine unrelated providers and the operator only sees a\n downstream 401-cascade — confusingly masked as a 429 once the\n routed-to provider (e.g. NVIDIA NIM) cools down. Backward\n compat is preserved: the resolver still returns\n ``all_api_methods()`` so module imports remain green; the\n real model call surfaces a separate, actionable error via\n ``_reraise_with_actionable_message``.\n \"\"\"\n # Point the OAuth credential path at a file we never create so\n # detection fails the same way ``/dev/null`` does at runtime.\n missing = tmp_path / \"missing.json\"\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"anthropic_oauth\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n # ``decepticon_core.utils.logging`` sets the parent decepticon logger\n # to ``propagate=False`` so library output doesn't double up in\n # apps that own their root handler. caplog hooks the root logger,\n # so without re-enabling propagation our ERROR record never\n # reaches pytest's capture buffer. Re-enable for the duration of\n # the call and let monkeypatch restore on teardown.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.ERROR):\n creds = _resolve_credentials()\n\n # Backward compat: still returns the all-API-methods fallback.\n assert creds.methods == Credentials.all_api_methods().methods\n\n # ERROR diagnostic must mention the env var name so an operator\n # scanning ``decepticon logs langgraph`` knows where to look.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert any(\"DECEPTICON_AUTH_PRIORITY\" in r.getMessage() for r in error_records), (\n f\"No ERROR log mentioned DECEPTICON_AUTH_PRIORITY. \"\n f\"Got: {[r.getMessage() for r in error_records]}\"\n )\n\n def test_implicit_priority_no_creds_stays_info_level(self, monkeypatch, caplog):\n \"\"\"Implicit priority (no DECEPTICON_AUTH_PRIORITY set) with no\n detectable credentials is the import-friendly CI/test path:\n we keep the existing INFO log and ``all_api_methods()`` return\n so module imports work without keys present. Only **explicit**\n priority with no matches gets escalated to ERROR.\n \"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n\n # Match the explicit-priority test's pattern so caplog sees the\n # records — the decepticon parent logger defaults to\n # ``propagate=False`` per ``decepticon_core.utils.logging``.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.DEBUG):\n creds = _resolve_credentials()\n\n assert creds.methods == Credentials.all_api_methods().methods\n # No ERROR records — this case stays informational.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert not error_records, (\n f\"Unexpected ERROR logs in implicit-priority path: \"\n f\"{[r.getMessage() for r in error_records]}\"\n )\n\n def test_ollama_local_only_returns_ollama_chain(self, monkeypatch):\n \"\"\"Issue #106: a user with only OLLAMA_API_BASE / OLLAMA_MODEL set\n (no API keys, no OAuth) must get a chain of one — Ollama only.\n Falling back to all-API-methods would produce 401 errors on every\n provider the user doesn't have.\"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL]\n\n def test_explicit_priority_with_ollama_local(self, monkeypatch):\n \"\"\"User opts into Ollama via explicit priority — the resolver\n recognizes it as configured when OLLAMA_API_BASE is set.\"\"\"\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"ollama_local,anthropic_api\")\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\n# ── Temperature handling (issue #107) ───────────────────────────────────\n\n\nclass TestTemperatureDrop:\n \"\"\"Claude Opus 4.7 rejects ``temperature`` regardless of route. The\n factory must drop it on every Opus 4 surface (anthropic/, auth/,\n openrouter/anthropic/) and keep it for everyone else.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _model_drops_temperature\n\n self._drops = _model_drops_temperature\n\n def test_anthropic_opus_drops_temperature(self):\n assert self._drops(\"anthropic/claude-opus-4-7\") is True\n\n def test_oauth_opus_drops_temperature(self):\n assert self._drops(\"auth/claude-opus-4-7\") is True\n\n def test_openrouter_opus_drops_temperature(self):\n assert self._drops(\"openrouter/anthropic/claude-opus-4-7\") is True\n\n def test_sonnet_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-sonnet-4-6\") is False\n\n def test_haiku_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-haiku-4-5\") is False\n\n def test_openai_keeps_temperature(self):\n assert self._drops(\"openai/gpt-5.5\") is False\n\n def test_ollama_keeps_temperature(self):\n assert self._drops(\"ollama_chat/qwen3-coder:30b\") is False\n\n\n# ── Actionable error translation (issue #107 + community feedback) ──────\n\n\nclass TestActionableErrorTranslation:\n \"\"\"The OSS user complaint: every upstream failure surfaces as 'An\n internal error occurred', stripping the message that would tell them\n what to fix. Each branch below verifies one class of error gets\n rewritten with a remediation hint and the model id that hit the\n failure.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _reraise_with_actionable_message\n\n self._translate = _reraise_with_actionable_message\n\n def test_no_fallback_model_group_branch(self):\n exc = Exception(\n \"litellm.BadRequestError: ... No fallback model group found \"\n \"for original model_group=anthropic/claude-opus-4-7.\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"no provider fallback\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_400_bad_request_branch(self):\n # openai.BadRequestError carries 'Error code: 400' in repr.\n exc = Exception(\"Error code: 400 - {'error': {'message': 'temperature is deprecated'}}\")\n type(exc).__name__ # noqa: B018 — sanity, ensures Exception default name\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n assert \"rejected the request (400)\" in str(info.value)\n\n def test_401_authentication_branch(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\"Error code: 401 - invalid_api_key\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"credentials (401)\" in msg\n assert \"decepticon onboard --reset\" in msg\n\n def test_429_ratelimit_branch(self):\n exc = type(\"RateLimitError\", (Exception,), {})(\"Error code: 429\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"rate limit (429)\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_404_notfound_with_ollama_hint(self):\n exc = type(\"NotFoundError\", (Exception,), {})(\"Error code: 404 - model not found\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"ollama_chat/nonexistent\")\n msg = str(info.value)\n assert \"404\" in msg\n assert \"OLLAMA_MODEL\" in msg\n\n def test_unmatched_error_passes_through(self):\n # Anything we don't recognize must NOT raise — the caller's\n # ``raise`` follows and re-raises the original exception with\n # full traceback.\n exc = ValueError(\"something completely unrelated\")\n # Should not raise from the helper.\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n\n def test_401_redacts_bearer_token_keeps_guidance(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\n \"Error code: 401 - {'error': 'invalid_api_key'} \"\n \"Authorization: Bearer sk-ant-SECRET123FAKEPLACEHOLDERTOKEN\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"sk-ant-SECRET123FAKEPLACEHOLDERTOKEN\" not in msg\n assert \"SECRET123FAKEPLACEHOLDERTOKEN\" not in msg\n assert \"[REDACTED]\" in msg\n assert \"credentials (401)\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n\n def test_400_redacts_api_key_kwarg_keeps_guidance(self):\n exc = Exception(\n \"Error code: 400 - bad request from provider with api_key=sk-SECRETfakeplaceholdervalue99\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"sk-SECRETfakeplaceholdervalue99\" not in msg\n assert \"SECRETfakeplaceholdervalue99\" not in msg\n assert \"[REDACTED]\" in msg\n assert \"rejected the request (400)\" in msg\n\n def test_redact_secrets_preserves_nonsecret_text(self):\n from decepticon.llm.factory import _redact_secrets\n\n text = \"No fallback model group found for model_group=anthropic/claude-opus-4-7.\"\n assert _redact_secrets(text) == text\n\n\n# ── DeepSeek V4 Pro reasoning_content passthrough ────────────────────────\n\n\nclass TestDeepSeekReasoningContent:\n \"\"\"Verify reasoning_content survives both the non-streaming and streaming\n paths so it can be sent back on subsequent API turns.\"\"\"\n\n def test_create_chat_result_extracts_reasoning_content(self):\n \"\"\"Non-streaming: _create_chat_result captures reasoning_content\n from the response dict's choice message.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Simulate the OpenAI response dict with reasoning_content\n response_dict = {\n \"choices\": [\n {\n \"message\": {\n \"role\": \"assistant\",\n \"content\": \"Four\",\n \"reasoning_content\": \"I think therefore I am\",\n },\n \"finish_reason\": \"stop\",\n }\n ],\n \"usage\": {},\n }\n\n # Mock the parent's _create_chat_result to return a ChatResult without reasoning_content\n msg = AIMessage(content=\"Four\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert (\n result.generations[0].message.additional_kwargs[\"reasoning_content\"]\n == \"I think therefore I am\"\n )\n\n def test_create_chat_result_skips_when_absent(self):\n \"\"\"Non-streaming: no crash when reasoning_content is missing.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n response_dict = {\n \"choices\": [\n {\"message\": {\"role\": \"assistant\", \"content\": \"hi\"}, \"finish_reason\": \"stop\"}\n ],\n \"usage\": {},\n }\n\n msg = AIMessage(content=\"hi\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert \"reasoning_content\" not in result.generations[0].message.additional_kwargs\n\n def test_convert_chunk_injects_reasoning_content(self):\n \"\"\"Streaming: _convert_chunk_to_generation_chunk captures\n reasoning_content from the raw delta and injects it into\n AIMessageChunk.additional_kwargs.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Build a fake raw SSE chunk dict with reasoning_content in the delta\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Let me think...\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n # The parent's _convert_chunk_to_generation_chunk builds the\n # ChatGenerationChunk but ignores reasoning_content. We mock it\n # to return a chunk without reasoning_content, then verify our\n # override injects it.\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n # Bind the real method to our mock instance\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert result.message.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n\n def test_convert_chunk_no_reasoning_leaves_kwargs_clean(self):\n \"\"\"Streaming: when delta has no reasoning_content, additional_kwargs\n is not polluted.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\"role\": \"assistant\", \"content\": \"hi\"},\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_reasoning_content_accumulates_across_chunks(self):\n \"\"\"Streaming: reasoning_content from multiple chunks concatenates\n via AIMessageChunk.__add__ (merge_dicts).\"\"\"\n from langchain_core.messages import AIMessageChunk\n\n c1 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"Let me \"})\n c2 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"think...\"})\n c3 = AIMessageChunk(content=\"Hello!\", additional_kwargs={})\n\n merged = c1 + c2 + c3\n assert merged.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n assert merged.content == \"Hello!\"\n\n def test_get_request_payload_injects_reasoning_content(self):\n \"\"\"Outbound: _get_request_payload injects reasoning_content from\n AIMessage.additional_kwargs into serialized assistant message dicts.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"hi\"),\n AIMessage(\n content=\"hello\",\n additional_kwargs={\"reasoning_content\": \"thinking...\"},\n ),\n ]\n\n # Mock super()._get_request_payload to return a payload without reasoning_content\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"hi\"},\n {\"role\": \"assistant\", \"content\": \"hello\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thinking...\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert payload[\"extra_body\"][\"thinking\"] == {\"type\": \"enabled\"}\n assert payload[\"reasoning_effort\"] == \"high\"\n\n def test_convert_chunk_empty_reasoning_content_ignored(self):\n \"\"\"Streaming: empty string reasoning_content in delta is ignored.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"hi\",\n \"reasoning_content\": \"\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n # Empty string is falsy — should not pollute additional_kwargs\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_none_delta_no_crash(self):\n \"\"\"Streaming: None delta in choices does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": None,\n \"finish_reason\": \"stop\",\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_empty_choices_no_crash(self):\n \"\"\"Streaming: empty choices array does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"choices\": []}\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_parent_returns_none(self):\n \"\"\"Streaming: when parent returns None, we return None too.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"type\": \"content.delta\"} # parent returns None for these\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=None,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is None\n\n def test_convert_chunk_beta_stream_format(self):\n \"\"\"Streaming: handles beta.chat.completions.stream nested chunk format.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Some LangChain versions nest under \"chunk\" key\n raw_chunk = {\n \"chunk\": {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Thinking via beta format...\",\n },\n \"finish_reason\": None,\n }\n ],\n },\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert (\n result.message.additional_kwargs[\"reasoning_content\"] == \"Thinking via beta format...\"\n )\n\n def test_get_request_payload_multiple_assistant_messages(self):\n \"\"\"Outbound: each assistant message gets its own reasoning_content.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"q1\"),\n AIMessage(content=\"a1\", additional_kwargs={\"reasoning_content\": \"thought1\"}),\n HumanMessage(content=\"q2\"),\n AIMessage(content=\"a2\", additional_kwargs={\"reasoning_content\": \"thought2\"}),\n ]\n\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"q1\"},\n {\"role\": \"assistant\", \"content\": \"a1\"},\n {\"role\": \"user\", \"content\": \"q2\"},\n {\"role\": \"assistant\", \"content\": \"a2\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thought1\"\n assert payload[\"messages\"][3][\"reasoning_content\"] == \"thought2\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert \"reasoning_content\" not in payload[\"messages\"][2]\n\n def test_model_detection(self):\n \"\"\"Factory routes DeepSeek V4 (pro + flash) and legacy reasoner.\n\n Per DeepSeek's API docs ``deepseek-reasoner`` is the deprecated\n alias for ``deepseek-v4-flash`` thinking mode, so v4-flash also\n returns ``reasoning_content`` and the API rejects subsequent\n tool turns when the field is omitted. Closes #201, #220.\n \"\"\"\n from decepticon.llm.factory import _model_is_deepseek_thinking\n\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-pro\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-flash\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-reasoner\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-chat\") is False\n assert _model_is_deepseek_thinking(\"openai/gpt-5.5\") is False\n\n\n# ── LLAMACPP_LOCAL credential detection (issue #151) ────────────────────\n\n\n_LLAMACPP_RELATED_ENV = (\n \"LLAMACPP_API_BASE\",\n \"LLAMACPP_MODEL\",\n \"LLAMACPP_API_KEY\",\n)\n\n\ndef _scrub_other_credentials(monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"Delete any env that would be detected as another credential.\n\n The credential-detection tests below need to assert \"only LLAMACPP\n is detected\"; without this scrub, a developer's exported\n ``ANTHROPIC_API_KEY`` would creep in and the assertion would fail\n locally only.\n \"\"\"\n for var in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"GROQ_API_KEY\",\n \"TOGETHER_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"COHERE_API_KEY\",\n \"MOONSHOT_API_KEY\",\n \"ZAI_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"GITHUB_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n ):\n monkeypatch.delenv(var, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n\nclass TestLlamacppLocalConfigured:\n \"\"\"``_llamacpp_local_configured`` is the gate for adding\n ``LLAMACPP_LOCAL`` to the credentials chain. Either env var being\n set is enough — neither requires the other, mirroring the\n LM Studio / Ollama detection contract.\n \"\"\"\n\n def test_returns_false_when_neither_env_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n assert _llamacpp_local_configured() is False\n\n def test_returns_true_when_only_base_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n assert _llamacpp_local_configured() is True\n\n def test_returns_true_when_only_model_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n assert _llamacpp_local_configured() is True\n\n def test_whitespace_does_not_count_as_set(self, monkeypatch):\n \"\"\"Stray ``LLAMACPP_API_BASE=`` lines in .env must not silently\n opt the user in — same fail-soft as the Ollama detector.\"\"\"\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \" \")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"\")\n assert _llamacpp_local_configured() is False\n\n\nclass TestResolveCredentialsForLlamacpp:\n \"\"\"End-to-end check that ``_resolve_credentials`` picks up the\n user's llama.cpp config — both the explicit-priority path and the\n \"only LLAMACPP_* detected\" auto-fallback at the bottom of\n ``_resolve_credentials``.\n \"\"\"\n\n def test_only_llamacpp_detected_returns_llamacpp_only_chain(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL]\n\n def test_priority_list_with_llamacpp_picks_it_up(self, monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"User wrote an explicit DECEPTICON_AUTH_PRIORITY listing\n llamacpp_local — the resolver must respect that ordering.\"\"\"\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen-coder\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"llamacpp_local,anthropic_api\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\nclass TestLLMTimeout:\n \"\"\"Whole-coroutine LLM request timeout (DECEPTICON_LLM_TIMEOUT_SECONDS).\"\"\"\n\n @pytest.fixture(autouse=True)\n def _isolate_timeout_env(self, monkeypatch: pytest.MonkeyPatch) -> None:\n monkeypatch.delenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_LLM__TIMEOUT\", raising=False)\n\n def test_call_with_timeout_raises_typed_exception(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> str:\n await asyncio.sleep(10)\n return \"done\"\n\n with pytest.raises(LLMTimeoutError, match=\"timed out after 0.01 seconds\"):\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n\n def test_call_with_timeout_preserves_original_timeout_as_cause(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> None:\n await asyncio.sleep(10)\n\n try:\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n except LLMTimeoutError as exc:\n assert isinstance(exc.__cause__, asyncio.TimeoutError)\n else:\n raise AssertionError(\"expected LLMTimeoutError\")\n\n def test_call_with_timeout_returns_successful_result(self) -> None:\n from decepticon.llm.factory import call_with_timeout\n\n async def fast_call() -> str:\n await asyncio.sleep(0)\n return \"ok\"\n\n assert asyncio.run(call_with_timeout(fast_call(), 1)) == \"ok\"\n\n def test_call_with_timeout_propagates_non_timeout_exceptions(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def failing_call() -> None:\n raise RuntimeError(\"upstream 500\")\n\n with pytest.raises(RuntimeError, match=\"upstream 500\"):\n asyncio.run(call_with_timeout(failing_call(), 1))\n # And not as a timeout error\n try:\n asyncio.run(call_with_timeout(failing_call(), 1))\n except LLMTimeoutError:\n raise AssertionError(\"non-timeout exception was retyped\")\n except RuntimeError:\n pass\n\n def test_env_override_takes_effect(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"3\")\n assert _resolve_llm_timeout_seconds() == 3.0\n\n def test_default_timeout_is_600_when_env_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.delenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", raising=False)\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_blank_env_falls_back_to_default(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \" \")\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_invalid_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"not-a-number\")\n with pytest.raises(ValueError):\n _resolve_llm_timeout_seconds()\n\n def test_non_positive_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"0\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"-5\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n def test_pydantic_nested_alias_takes_effect(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"900\")\n assert _resolve_llm_timeout_seconds() == 900.0\n\n def test_explicit_env_wins_over_pydantic_alias(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"120\")\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"900\")\n assert _resolve_llm_timeout_seconds() == 120.0\n\n def test_alias_invalid_value_attributes_error_to_alias(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"nonsense\")\n with pytest.raises(ValueError, match=\"DECEPTICON_LLM__TIMEOUT\"):\n _resolve_llm_timeout_seconds()\n\n def test_alias_non_positive_attributes_error_to_alias(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"0\")\n with pytest.raises(ValueError, match=\"DECEPTICON_LLM__TIMEOUT.*greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n def test_ainvoke_resolves_timeout_before_creating_request_coroutine(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n \"\"\"A misconfigured timeout env must fail loudly *without* creating\n (and leaking un-awaited) the upstream request coroutine. Regression:\n the timeout was previously resolved as the second argument to\n ``call_with_timeout``, so ``super().ainvoke(...)`` was already\n evaluated — a ValueError then left that coroutine never awaited.\"\"\"\n from langchain_openai import ChatOpenAI\n from pydantic import SecretStr\n\n from decepticon.llm.factory import _ProxiedChatOpenAI\n\n called = False\n\n async def _tracking_ainvoke(self, *args, **kwargs): # noqa: ANN001\n nonlocal called\n called = True\n return \"should-not-happen\"\n\n monkeypatch.setattr(ChatOpenAI, \"ainvoke\", _tracking_ainvoke)\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"-5\")\n\n model = _ProxiedChatOpenAI(\n model=\"openai/gpt-5.5\",\n base_url=\"http://localhost:4000\",\n api_key=SecretStr(\"test-key\"),\n )\n with pytest.raises(ValueError, match=\"greater than 0\"):\n asyncio.run(model.ainvoke(\"hi\"))\n assert called is False, \"upstream request coroutine must not be created on bad timeout\"\n"}, "d3bc3f09b0942c49aa3f7c120529dd3b71455871": {"t": 1785755385.767353, "n": 62481, "h": "8201ae5b34a90ede", "c": "\"\"\"Unit tests for decepticon.llm.factory.\"\"\"\n\nimport asyncio\nimport logging\n\nimport pytest\n\nfrom decepticon.llm.factory import (\n LLMFactory,\n _is_real_key,\n _llamacpp_local_configured,\n _method_is_configured,\n _oauth_credentials_present,\n _oauth_env_credentials_present,\n _resolve_credentials,\n)\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\n\n\nclass TestIsRealKey:\n \"\"\"Vendor-aware API key validation.\n\n The launcher writes ``your-…-key-here`` placeholders into .env so the\n user can later swap in a real key. The factory needs to reject those\n plus any obvious junk (short strings, ``placeholder``/``not-used``\n markers, vendor-prefix mismatches) so the credentials inventory\n reflects what actually works at request time.\n \"\"\"\n\n def test_rejects_empty_and_placeholder_template(self) -> None:\n assert _is_real_key(\"\") is False\n assert _is_real_key(\" \") is False\n assert _is_real_key(\"your-anthropic-key-here\") is False\n assert _is_real_key(\"YOUR-OPENAI-KEY-HERE\") is False # case-insensitive\n\n def test_rejects_short_strings(self) -> None:\n # Under 24 chars — every vendor-issued key exceeds this.\n assert _is_real_key(\"sk-tooshort\") is False\n\n def test_rejects_placeholder_tokens_in_value(self) -> None:\n long_enough = \"x\" * 30\n for token in (\"placeholder\", \"not-used\", \"dummy\", \"fake\", \"example\"):\n assert _is_real_key(f\"sk-{token}-{long_enough}\") is False, token\n\n def test_accepts_realistic_keys_without_method(self) -> None:\n # Without method context, prefix check is skipped.\n assert _is_real_key(\"sk-ant-api03-realtokenfortestingauthrouting12345\") is True\n assert _is_real_key(\"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\") is True\n\n def test_rejects_wrong_vendor_prefix(self) -> None:\n # An OpenAI-shaped key in the Anthropic slot must be caught.\n openai_key = \"sk-proj-realopenaitokenfortestingauthrouting12345\"\n assert _is_real_key(openai_key, AuthMethod.ANTHROPIC_API) is False\n # …and vice versa.\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.GOOGLE_API) is False\n\n def test_accepts_correct_vendor_prefix(self) -> None:\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.ANTHROPIC_API) is True\n google_key = \"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\"\n assert _is_real_key(google_key, AuthMethod.GOOGLE_API) is True\n\n\nclass TestOAuthCredentialsPresent:\n \"\"\"OAuth detection requires the credential file alongside the boolean.\n\n Without the file check, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place OAuth in\n every fallback chain and generate one 401 per request.\n \"\"\"\n\n def test_returns_false_when_file_missing(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(tmp_path / \"absent.json\"))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_when_file_is_empty(self, monkeypatch, tmp_path) -> None:\n # ``/dev/null``-style mounts read as empty — must fail closed.\n empty = tmp_path / \"empty.json\"\n empty.write_text(\"\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(empty))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_on_invalid_json(self, monkeypatch, tmp_path) -> None:\n bad = tmp_path / \"bad.json\"\n bad.write_text(\"{not-json\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(bad))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_true_when_file_is_well_formed(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"credentials.json\"\n good.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeef\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is True\n\n def test_codex_path_via_env_override(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"auth.json\"\n good.write_text('{\"tokens\": {\"access_token\": \"ABC\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CODEX_AUTH_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.OPENAI_OAUTH) is True\n\n\nclass TestOAuthEnvCredentials:\n \"\"\"B12: env-backed OAuth creds count as configured, not just on-disk files.\n Before the fix, ``_method_is_configured`` required a credential *file* even\n though the LiteLLM handlers also accept env-var tokens (GEMINI_ACCESS_TOKEN,\n GROK_SESSION_TOKEN, COPILOT_REFRESH_TOKEN, …). Env-only setups were silently\n dropped from the fallback chain.\n \"\"\"\n\n def test_env_present_returns_true_for_each_method(self, monkeypatch) -> None:\n cases = {\n AuthMethod.GOOGLE_OAUTH: \"GEMINI_ACCESS_TOKEN\",\n AuthMethod.COPILOT_OAUTH: \"COPILOT_REFRESH_TOKEN\",\n AuthMethod.GROK_OAUTH: \"GROK_SESSION_TOKEN\",\n AuthMethod.PERPLEXITY_OAUTH: \"PERPLEXITY_SESSION_TOKEN\",\n AuthMethod.ANTHROPIC_OAUTH: \"ANTHROPIC_OAUTH_TOKEN\",\n }\n for method, env_var in cases.items():\n monkeypatch.setenv(env_var, \"tok-from-env\")\n assert _oauth_env_credentials_present(method) is True\n monkeypatch.delenv(env_var, raising=False)\n\n def test_env_only_method_is_configured_without_file(self, monkeypatch, tmp_path) -> None:\n # Flag true + env token, but the credential file is absent.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.setenv(\"DECEPTICON_AUTH_GEMINI\", \"true\")\n monkeypatch.setenv(\"GEMINI_ACCESS_TOKEN\", \"ya29.env-token\")\n assert _oauth_credentials_present(AuthMethod.GOOGLE_OAUTH) is False\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is True\n\n def test_method_not_configured_without_file_or_env(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.setenv(\"DECEPTICON_AUTH_GEMINI\", \"true\")\n monkeypatch.delenv(\"GEMINI_ACCESS_TOKEN\", raising=False)\n monkeypatch.delenv(\"GEMINI_SESSION_COOKIES\", raising=False)\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is False\n\n def test_env_creds_still_require_intent_flag(self, monkeypatch, tmp_path) -> None:\n # Env token present but the boolean intent flag is off → not configured.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.delenv(\"DECEPTICON_AUTH_GEMINI\", raising=False)\n monkeypatch.setenv(\"GEMINI_ACCESS_TOKEN\", \"ya29.env-token\")\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is False\n\n def test_codex_has_no_env_token_override(self, monkeypatch) -> None:\n # Codex authenticates only from its file; no env-token override exists.\n monkeypatch.setenv(\"CODEX_ACCESS_TOKEN\", \"should-not-count\")\n assert _oauth_env_credentials_present(AuthMethod.OPENAI_OAUTH) is False\n\n\nclass TestLLMFactory:\n def setup_method(self):\n self.proxy = ProxyConfig(url=\"http://localhost:4000\", api_key=\"test-key\")\n # Build an explicit mapping so the test doesn't depend on env vars.\n creds = Credentials.all_api_methods()\n self.mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n self.factory = LLMFactory(self.proxy, self.mapping)\n\n def test_factory_initializes(self):\n assert self.factory.proxy_url == \"http://localhost:4000\"\n\n def test_get_model_returns_chat_model(self):\n model = self.factory.get_model(\"recon\")\n assert model is not None\n assert model.model_name == \"anthropic/claude-haiku-4-5\"\n\n def test_get_model_caches_instances(self):\n m1 = self.factory.get_model(\"recon\")\n m2 = self.factory.get_model(\"recon\")\n assert m1 is m2\n\n def test_get_model_different_roles_different_models(self):\n recon = self.factory.get_model(\"recon\")\n decepticon = self.factory.get_model(\"decepticon\")\n assert recon is not decepticon\n assert recon.model_name != decepticon.model_name\n\n def test_get_model_unknown_role_raises(self):\n with pytest.raises(KeyError, match=\"No model assignment\"):\n self.factory.get_model(\"nonexistent\")\n\n def test_router_accessible(self):\n assert self.factory.router is not None\n\n def test_get_fallback_models_full_chain(self):\n models = self.factory.get_fallback_models(\"recon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5-nano\",\n \"gemini/gemini-2.5-flash-lite\",\n \"deepseek/deepseek-v4-flash\",\n \"openrouter/anthropic/claude-haiku-4-5\",\n \"nvidia_nim/meta/llama-3.2-3b-instruct\",\n ]\n\n def test_get_fallback_models_high_tier_includes_all_methods(self):\n models = self.factory.get_fallback_models(\"decepticon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5.5\",\n \"gemini/gemini-2.5-pro\",\n \"minimax/MiniMax-M3\",\n \"deepseek/deepseek-v4-pro\",\n \"xai/grok-4.3\",\n \"mistral/mistral-large-latest\",\n \"openrouter/anthropic/claude-opus-4-8\",\n \"nvidia_nim/meta/llama-3.3-70b-instruct\",\n ]\n\n def test_get_fallback_models_without_fallback(self):\n # Single-credential mapping → no fallback.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n factory = LLMFactory(self.proxy, mapping)\n assert factory.get_fallback_models(\"recon\") == []\n\n def test_explicit_credentials_param(self):\n # Constructor accepts a Credentials object instead of a full mapping.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n factory = LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n assert factory.get_model(\"decepticon\").model_name == \"openai/gpt-5.5\"\n\n\nclass TestLLMFactoryEmptyChainValidation:\n \"\"\"A credentials inventory whose AuthMethods lack a (method, tier) entry\n for some required tier resolves to an EMPTY model chain at that tier.\n ``LLMModelMapping.from_credentials_and_profile`` silently skips such\n roles, so the failure surfaces much later — either as a confusing\n ``KeyError: No model assignment for role: ...`` at ``get_model`` time\n or, when a caller routes through middleware, as a primary-less\n ``ModelFallbackMiddleware([])``. Validate at construction so the\n operator gets one actionable error naming the empty tier(s) and the\n configured credentials inventory.\"\"\"\n\n def setup_method(self) -> None:\n self.proxy = ProxyConfig(url=\"http://localhost:4000\", api_key=\"test-key\")\n\n def test_empty_tier_raises_at_init_with_inventory_listed(self) -> None:\n # MINIMAX_API has only HIGH+MID entries in METHOD_MODELS — no LOW.\n # ECO routes ``recon`` (and other LOW roles) to Tier.LOW, so the\n # LOW chain resolves to []. The factory must refuse to construct.\n creds = Credentials(methods=[AuthMethod.MINIMAX_API])\n with pytest.raises(ValueError) as exc_info:\n LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n msg = str(exc_info.value)\n # Names the empty tier so the operator knows what to fix.\n assert \"low\" in msg.lower()\n # Echoes the configured credentials so the operator can audit\n # what was actually detected (vs. what they think they set).\n assert AuthMethod.MINIMAX_API.value in msg\n\n def test_test_profile_empty_high_chain_raises(self) -> None:\n # MINIMAX_API covers HIGH+MID but not LOW; TEST profile forces every\n # role to LOW → empty chain across the board.\n creds = Credentials(methods=[AuthMethod.MINIMAX_API])\n with pytest.raises(ValueError, match=\"(?i)low\"):\n LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.TEST)\n\n def test_valid_inventory_still_constructs(self) -> None:\n # OPENAI_API covers all three tiers — no empty chain, no regression.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n factory = LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n assert factory.get_model(\"recon\").model_name == \"openai/gpt-5-nano\"\n assert factory.get_model(\"decepticon\").model_name == \"openai/gpt-5.5\"\n\n def test_explicit_mapping_bypasses_validation(self) -> None:\n # When the caller supplies a mapping directly, they own its shape —\n # the credentials-based validation does not apply (existing tests\n # build empty/partial mappings explicitly).\n factory = LLMFactory(self.proxy, mapping=LLMModelMapping())\n assert factory.proxy_url == \"http://localhost:4000\"\n\n\nclass TestLLMFactoryHealthCheck:\n def test_health_check_returns_false_when_no_proxy(self):\n proxy = ProxyConfig(url=\"http://localhost:19999\")\n factory = LLMFactory(proxy, mapping=LLMModelMapping())\n assert asyncio.run(factory.health_check()) is False\n\n\nclass TestResolveCredentials:\n def test_real_keys_only(self, monkeypatch):\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"your-openai-key-here\") # placeholder\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_API]\n\n def test_oauth_only(self, monkeypatch, tmp_path):\n # OAuth detection requires the credential FILE alongside the\n # boolean — point Claude Code at a temp credentials file so the\n # test runs deterministically regardless of host state.\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_OAUTH]\n\n def test_oauth_flag_without_credential_file_is_dropped(self, monkeypatch, tmp_path):\n \"\"\"Stale ``DECEPTICON_AUTH_CLAUDE_CODE=true`` after ``codex logout``\n (or after the user deleted ``~/.claude/.credentials.json``) must\n not place the OAuth method into the chain — otherwise every\n request 401s before falling back to the next provider.\n \"\"\"\n # Point both the primary and legacy fallback paths at tmp_path so\n # any ``~/.claude/.credentials.json`` or\n # ``~/.config/anthropic/q/tokens.json`` on the dev host doesn't\n # accidentally satisfy the file-presence check.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n missing = tmp_path / \"missing.json\" # never created\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n creds = _resolve_credentials()\n assert AuthMethod.ANTHROPIC_OAUTH not in creds.methods\n\n def test_oauth_plus_api_priority_default(self, monkeypatch, tmp_path):\n # Default priority is anthropic_oauth > anthropic_api > openai_api ...\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n ]\n\n def test_explicit_priority_override(self, monkeypatch):\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"openai_api,anthropic_api\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n monkeypatch.delenv(\"GEMINI_API_KEY\", raising=False)\n monkeypatch.delenv(\"MINIMAX_API_KEY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OPENAI_API, AuthMethod.ANTHROPIC_API]\n\n def test_placeholder_falls_back_to_all_api_methods(self, monkeypatch):\n \"\"\"When every detected method is a placeholder/missing, the resolver\n falls back to the all-API-methods inventory so module-level agent\n constructors stay importable in CI / dev shells without keys.\"\"\"\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"your-anthropic-key-here\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n ]\n\n def test_explicit_priority_no_creds_logs_error(self, monkeypatch, tmp_path, caplog):\n \"\"\"An explicit ``DECEPTICON_AUTH_PRIORITY`` whose every listed\n method fails detection (typical of a broken OAuth credentials\n mount — e.g. ``CLAUDE_CREDENTIALS_VOLUME`` unset and the\n compose file bound ``/dev/null`` into the container) must\n surface the root cause at ERROR level.\n\n Otherwise the silent fallback to ``all_api_methods()`` runs\n through nine unrelated providers and the operator only sees a\n downstream 401-cascade — confusingly masked as a 429 once the\n routed-to provider (e.g. NVIDIA NIM) cools down. Backward\n compat is preserved: the resolver still returns\n ``all_api_methods()`` so module imports remain green; the\n real model call surfaces a separate, actionable error via\n ``_reraise_with_actionable_message``.\n \"\"\"\n # Point the OAuth credential path at a file we never create so\n # detection fails the same way ``/dev/null`` does at runtime.\n missing = tmp_path / \"missing.json\"\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"anthropic_oauth\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n # ``decepticon_core.utils.logging`` sets the parent decepticon logger\n # to ``propagate=False`` so library output doesn't double up in\n # apps that own their root handler. caplog hooks the root logger,\n # so without re-enabling propagation our ERROR record never\n # reaches pytest's capture buffer. Re-enable for the duration of\n # the call and let monkeypatch restore on teardown.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.ERROR):\n creds = _resolve_credentials()\n\n # Backward compat: still returns the all-API-methods fallback.\n assert creds.methods == Credentials.all_api_methods().methods\n\n # ERROR diagnostic must mention the env var name so an operator\n # scanning ``decepticon logs langgraph`` knows where to look.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert any(\"DECEPTICON_AUTH_PRIORITY\" in r.getMessage() for r in error_records), (\n f\"No ERROR log mentioned DECEPTICON_AUTH_PRIORITY. \"\n f\"Got: {[r.getMessage() for r in error_records]}\"\n )\n\n def test_implicit_priority_no_creds_stays_info_level(self, monkeypatch, caplog):\n \"\"\"Implicit priority (no DECEPTICON_AUTH_PRIORITY set) with no\n detectable credentials is the import-friendly CI/test path:\n we keep the existing INFO log and ``all_api_methods()`` return\n so module imports work without keys present. Only **explicit**\n priority with no matches gets escalated to ERROR.\n \"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n\n # Match the explicit-priority test's pattern so caplog sees the\n # records — the decepticon parent logger defaults to\n # ``propagate=False`` per ``decepticon_core.utils.logging``.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.DEBUG):\n creds = _resolve_credentials()\n\n assert creds.methods == Credentials.all_api_methods().methods\n # No ERROR records — this case stays informational.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert not error_records, (\n f\"Unexpected ERROR logs in implicit-priority path: \"\n f\"{[r.getMessage() for r in error_records]}\"\n )\n\n def test_ollama_local_only_returns_ollama_chain(self, monkeypatch):\n \"\"\"Issue #106: a user with only OLLAMA_API_BASE / OLLAMA_MODEL set\n (no API keys, no OAuth) must get a chain of one — Ollama only.\n Falling back to all-API-methods would produce 401 errors on every\n provider the user doesn't have.\"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL]\n\n def test_explicit_priority_with_ollama_local(self, monkeypatch):\n \"\"\"User opts into Ollama via explicit priority — the resolver\n recognizes it as configured when OLLAMA_API_BASE is set.\"\"\"\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"ollama_local,anthropic_api\")\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\n# ── Temperature handling (issue #107) ───────────────────────────────────\n\n\nclass TestTemperatureDrop:\n \"\"\"Claude Opus 4.7 rejects ``temperature`` regardless of route. The\n factory must drop it on every Opus 4 surface (anthropic/, auth/,\n openrouter/anthropic/) and keep it for everyone else.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _model_drops_temperature\n\n self._drops = _model_drops_temperature\n\n def test_anthropic_opus_drops_temperature(self):\n assert self._drops(\"anthropic/claude-opus-4-7\") is True\n\n def test_oauth_opus_drops_temperature(self):\n assert self._drops(\"auth/claude-opus-4-7\") is True\n\n def test_openrouter_opus_drops_temperature(self):\n assert self._drops(\"openrouter/anthropic/claude-opus-4-7\") is True\n\n def test_sonnet_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-sonnet-4-6\") is False\n\n def test_haiku_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-haiku-4-5\") is False\n\n def test_openai_keeps_temperature(self):\n assert self._drops(\"openai/gpt-5.5\") is False\n\n def test_ollama_keeps_temperature(self):\n assert self._drops(\"ollama_chat/qwen3-coder:30b\") is False\n\n\n# ── Actionable error translation (issue #107 + community feedback) ──────\n\n\nclass TestActionableErrorTranslation:\n \"\"\"The OSS user complaint: every upstream failure surfaces as 'An\n internal error occurred', stripping the message that would tell them\n what to fix. Each branch below verifies one class of error gets\n rewritten with a remediation hint and the model id that hit the\n failure.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _reraise_with_actionable_message\n\n self._translate = _reraise_with_actionable_message\n\n def test_no_fallback_model_group_branch(self):\n exc = Exception(\n \"litellm.BadRequestError: ... No fallback model group found \"\n \"for original model_group=anthropic/claude-opus-4-7.\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"no provider fallback\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_400_bad_request_branch(self):\n # openai.BadRequestError carries 'Error code: 400' in repr.\n exc = Exception(\"Error code: 400 - {'error': {'message': 'temperature is deprecated'}}\")\n type(exc).__name__ # noqa: B018 — sanity, ensures Exception default name\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n assert \"rejected the request (400)\" in str(info.value)\n\n def test_401_authentication_branch(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\"Error code: 401 - invalid_api_key\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"credentials (401)\" in msg\n assert \"decepticon onboard --reset\" in msg\n\n def test_429_ratelimit_branch(self):\n exc = type(\"RateLimitError\", (Exception,), {})(\"Error code: 429\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"rate limit (429)\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_404_notfound_with_ollama_hint(self):\n exc = type(\"NotFoundError\", (Exception,), {})(\"Error code: 404 - model not found\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"ollama_chat/nonexistent\")\n msg = str(info.value)\n assert \"404\" in msg\n assert \"OLLAMA_MODEL\" in msg\n\n def test_unmatched_error_passes_through(self):\n # Anything we don't recognize must NOT raise — the caller's\n # ``raise`` follows and re-raises the original exception with\n # full traceback.\n exc = ValueError(\"something completely unrelated\")\n # Should not raise from the helper.\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n\n def test_401_redacts_bearer_token_keeps_guidance(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\n \"Error code: 401 - {'error': 'invalid_api_key'} \"\n \"Authorization: Bearer sk-ant-SECRET123FAKEPLACEHOLDERTOKEN\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"sk-ant-SECRET123FAKEPLACEHOLDERTOKEN\" not in msg\n assert \"SECRET123FAKEPLACEHOLDERTOKEN\" not in msg\n assert \"[REDACTED]\" in msg\n assert \"credentials (401)\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n\n def test_400_redacts_api_key_kwarg_keeps_guidance(self):\n exc = Exception(\n \"Error code: 400 - bad request from provider with api_key=sk-SECRETfakeplaceholdervalue99\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"sk-SECRETfakeplaceholdervalue99\" not in msg\n assert \"SECRETfakeplaceholdervalue99\" not in msg\n assert \"[REDACTED]\" in msg\n assert \"rejected the request (400)\" in msg\n\n def test_redact_secrets_preserves_nonsecret_text(self):\n from decepticon.llm.factory import _redact_secrets\n\n text = \"No fallback model group found for model_group=anthropic/claude-opus-4-7.\"\n assert _redact_secrets(text) == text\n\n\n# ── DeepSeek V4 Pro reasoning_content passthrough ────────────────────────\n\n\nclass TestDeepSeekReasoningContent:\n \"\"\"Verify reasoning_content survives both the non-streaming and streaming\n paths so it can be sent back on subsequent API turns.\"\"\"\n\n def test_create_chat_result_extracts_reasoning_content(self):\n \"\"\"Non-streaming: _create_chat_result captures reasoning_content\n from the response dict's choice message.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Simulate the OpenAI response dict with reasoning_content\n response_dict = {\n \"choices\": [\n {\n \"message\": {\n \"role\": \"assistant\",\n \"content\": \"Four\",\n \"reasoning_content\": \"I think therefore I am\",\n },\n \"finish_reason\": \"stop\",\n }\n ],\n \"usage\": {},\n }\n\n # Mock the parent's _create_chat_result to return a ChatResult without reasoning_content\n msg = AIMessage(content=\"Four\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert (\n result.generations[0].message.additional_kwargs[\"reasoning_content\"]\n == \"I think therefore I am\"\n )\n\n def test_create_chat_result_skips_when_absent(self):\n \"\"\"Non-streaming: no crash when reasoning_content is missing.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n response_dict = {\n \"choices\": [\n {\"message\": {\"role\": \"assistant\", \"content\": \"hi\"}, \"finish_reason\": \"stop\"}\n ],\n \"usage\": {},\n }\n\n msg = AIMessage(content=\"hi\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert \"reasoning_content\" not in result.generations[0].message.additional_kwargs\n\n def test_convert_chunk_injects_reasoning_content(self):\n \"\"\"Streaming: _convert_chunk_to_generation_chunk captures\n reasoning_content from the raw delta and injects it into\n AIMessageChunk.additional_kwargs.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Build a fake raw SSE chunk dict with reasoning_content in the delta\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Let me think...\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n # The parent's _convert_chunk_to_generation_chunk builds the\n # ChatGenerationChunk but ignores reasoning_content. We mock it\n # to return a chunk without reasoning_content, then verify our\n # override injects it.\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n # Bind the real method to our mock instance\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert result.message.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n\n def test_convert_chunk_no_reasoning_leaves_kwargs_clean(self):\n \"\"\"Streaming: when delta has no reasoning_content, additional_kwargs\n is not polluted.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\"role\": \"assistant\", \"content\": \"hi\"},\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_reasoning_content_accumulates_across_chunks(self):\n \"\"\"Streaming: reasoning_content from multiple chunks concatenates\n via AIMessageChunk.__add__ (merge_dicts).\"\"\"\n from langchain_core.messages import AIMessageChunk\n\n c1 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"Let me \"})\n c2 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"think...\"})\n c3 = AIMessageChunk(content=\"Hello!\", additional_kwargs={})\n\n merged = c1 + c2 + c3\n assert merged.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n assert merged.content == \"Hello!\"\n\n def test_get_request_payload_injects_reasoning_content(self):\n \"\"\"Outbound: _get_request_payload injects reasoning_content from\n AIMessage.additional_kwargs into serialized assistant message dicts.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"hi\"),\n AIMessage(\n content=\"hello\",\n additional_kwargs={\"reasoning_content\": \"thinking...\"},\n ),\n ]\n\n # Mock super()._get_request_payload to return a payload without reasoning_content\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"hi\"},\n {\"role\": \"assistant\", \"content\": \"hello\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thinking...\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert payload[\"extra_body\"][\"thinking\"] == {\"type\": \"enabled\"}\n assert payload[\"reasoning_effort\"] == \"high\"\n\n def test_convert_chunk_empty_reasoning_content_ignored(self):\n \"\"\"Streaming: empty string reasoning_content in delta is ignored.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"hi\",\n \"reasoning_content\": \"\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n # Empty string is falsy — should not pollute additional_kwargs\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_none_delta_no_crash(self):\n \"\"\"Streaming: None delta in choices does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": None,\n \"finish_reason\": \"stop\",\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_empty_choices_no_crash(self):\n \"\"\"Streaming: empty choices array does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"choices\": []}\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_parent_returns_none(self):\n \"\"\"Streaming: when parent returns None, we return None too.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"type\": \"content.delta\"} # parent returns None for these\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=None,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is None\n\n def test_convert_chunk_beta_stream_format(self):\n \"\"\"Streaming: handles beta.chat.completions.stream nested chunk format.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Some LangChain versions nest under \"chunk\" key\n raw_chunk = {\n \"chunk\": {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Thinking via beta format...\",\n },\n \"finish_reason\": None,\n }\n ],\n },\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert (\n result.message.additional_kwargs[\"reasoning_content\"] == \"Thinking via beta format...\"\n )\n\n def test_get_request_payload_multiple_assistant_messages(self):\n \"\"\"Outbound: each assistant message gets its own reasoning_content.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"q1\"),\n AIMessage(content=\"a1\", additional_kwargs={\"reasoning_content\": \"thought1\"}),\n HumanMessage(content=\"q2\"),\n AIMessage(content=\"a2\", additional_kwargs={\"reasoning_content\": \"thought2\"}),\n ]\n\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"q1\"},\n {\"role\": \"assistant\", \"content\": \"a1\"},\n {\"role\": \"user\", \"content\": \"q2\"},\n {\"role\": \"assistant\", \"content\": \"a2\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thought1\"\n assert payload[\"messages\"][3][\"reasoning_content\"] == \"thought2\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert \"reasoning_content\" not in payload[\"messages\"][2]\n\n def test_model_detection(self):\n \"\"\"Factory routes DeepSeek V4 (pro + flash) and legacy reasoner.\n\n Per DeepSeek's API docs ``deepseek-reasoner`` is the deprecated\n alias for ``deepseek-v4-flash`` thinking mode, so v4-flash also\n returns ``reasoning_content`` and the API rejects subsequent\n tool turns when the field is omitted. Closes #201, #220.\n \"\"\"\n from decepticon.llm.factory import _model_is_deepseek_thinking\n\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-pro\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-flash\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-reasoner\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-chat\") is False\n assert _model_is_deepseek_thinking(\"openai/gpt-5.5\") is False\n\n\n# ── LLAMACPP_LOCAL credential detection (issue #151) ────────────────────\n\n\n_LLAMACPP_RELATED_ENV = (\n \"LLAMACPP_API_BASE\",\n \"LLAMACPP_MODEL\",\n \"LLAMACPP_API_KEY\",\n)\n\n\ndef _scrub_other_credentials(monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"Delete any env that would be detected as another credential.\n\n The credential-detection tests below need to assert \"only LLAMACPP\n is detected\"; without this scrub, a developer's exported\n ``ANTHROPIC_API_KEY`` would creep in and the assertion would fail\n locally only.\n \"\"\"\n for var in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"GROQ_API_KEY\",\n \"TOGETHER_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"COHERE_API_KEY\",\n \"MOONSHOT_API_KEY\",\n \"ZAI_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"GITHUB_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n ):\n monkeypatch.delenv(var, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n\nclass TestLlamacppLocalConfigured:\n \"\"\"``_llamacpp_local_configured`` is the gate for adding\n ``LLAMACPP_LOCAL`` to the credentials chain. Either env var being\n set is enough — neither requires the other, mirroring the\n LM Studio / Ollama detection contract.\n \"\"\"\n\n def test_returns_false_when_neither_env_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n assert _llamacpp_local_configured() is False\n\n def test_returns_true_when_only_base_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n assert _llamacpp_local_configured() is True\n\n def test_returns_true_when_only_model_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n assert _llamacpp_local_configured() is True\n\n def test_whitespace_does_not_count_as_set(self, monkeypatch):\n \"\"\"Stray ``LLAMACPP_API_BASE=`` lines in .env must not silently\n opt the user in — same fail-soft as the Ollama detector.\"\"\"\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \" \")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"\")\n assert _llamacpp_local_configured() is False\n\n\nclass TestResolveCredentialsForLlamacpp:\n \"\"\"End-to-end check that ``_resolve_credentials`` picks up the\n user's llama.cpp config — both the explicit-priority path and the\n \"only LLAMACPP_* detected\" auto-fallback at the bottom of\n ``_resolve_credentials``.\n \"\"\"\n\n def test_only_llamacpp_detected_returns_llamacpp_only_chain(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL]\n\n def test_priority_list_with_llamacpp_picks_it_up(self, monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"User wrote an explicit DECEPTICON_AUTH_PRIORITY listing\n llamacpp_local — the resolver must respect that ordering.\"\"\"\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen-coder\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"llamacpp_local,anthropic_api\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\nclass TestLLMTimeout:\n \"\"\"Whole-coroutine LLM request timeout (DECEPTICON_LLM_TIMEOUT_SECONDS).\"\"\"\n\n @pytest.fixture(autouse=True)\n def _isolate_timeout_env(self, monkeypatch: pytest.MonkeyPatch) -> None:\n monkeypatch.delenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_LLM__TIMEOUT\", raising=False)\n\n def test_call_with_timeout_raises_typed_exception(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> str:\n await asyncio.sleep(10)\n return \"done\"\n\n with pytest.raises(LLMTimeoutError, match=\"timed out after 0.01 seconds\"):\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n\n def test_call_with_timeout_preserves_original_timeout_as_cause(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> None:\n await asyncio.sleep(10)\n\n try:\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n except LLMTimeoutError as exc:\n assert isinstance(exc.__cause__, asyncio.TimeoutError)\n else:\n raise AssertionError(\"expected LLMTimeoutError\")\n\n def test_call_with_timeout_returns_successful_result(self) -> None:\n from decepticon.llm.factory import call_with_timeout\n\n async def fast_call() -> str:\n await asyncio.sleep(0)\n return \"ok\"\n\n assert asyncio.run(call_with_timeout(fast_call(), 1)) == \"ok\"\n\n def test_call_with_timeout_propagates_non_timeout_exceptions(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def failing_call() -> None:\n raise RuntimeError(\"upstream 500\")\n\n with pytest.raises(RuntimeError, match=\"upstream 500\"):\n asyncio.run(call_with_timeout(failing_call(), 1))\n # And not as a timeout error\n try:\n asyncio.run(call_with_timeout(failing_call(), 1))\n except LLMTimeoutError:\n raise AssertionError(\"non-timeout exception was retyped\")\n except RuntimeError:\n pass\n\n def test_env_override_takes_effect(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"3\")\n assert _resolve_llm_timeout_seconds() == 3.0\n\n def test_default_timeout_is_600_when_env_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.delenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", raising=False)\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_blank_env_falls_back_to_default(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \" \")\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_invalid_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"not-a-number\")\n with pytest.raises(ValueError):\n _resolve_llm_timeout_seconds()\n\n def test_non_positive_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"0\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"-5\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n def test_pydantic_nested_alias_takes_effect(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"900\")\n assert _resolve_llm_timeout_seconds() == 900.0\n\n def test_explicit_env_wins_over_pydantic_alias(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"120\")\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"900\")\n assert _resolve_llm_timeout_seconds() == 120.0\n\n def test_alias_invalid_value_attributes_error_to_alias(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"nonsense\")\n with pytest.raises(ValueError, match=\"DECEPTICON_LLM__TIMEOUT\"):\n _resolve_llm_timeout_seconds()\n\n def test_alias_non_positive_attributes_error_to_alias(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"0\")\n with pytest.raises(ValueError, match=\"DECEPTICON_LLM__TIMEOUT.*greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n def test_ainvoke_resolves_timeout_before_creating_request_coroutine(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n \"\"\"A misconfigured timeout env must fail loudly *without* creating\n (and leaking un-awaited) the upstream request coroutine. Regression:\n the timeout was previously resolved as the second argument to\n ``call_with_timeout``, so ``super().ainvoke(...)`` was already\n evaluated — a ValueError then left that coroutine never awaited.\"\"\"\n from langchain_openai import ChatOpenAI\n from pydantic import SecretStr\n\n from decepticon.llm.factory import _ProxiedChatOpenAI\n\n called = False\n\n async def _tracking_ainvoke(self, *args, **kwargs): # noqa: ANN001\n nonlocal called\n called = True\n return \"should-not-happen\"\n\n monkeypatch.setattr(ChatOpenAI, \"ainvoke\", _tracking_ainvoke)\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"-5\")\n\n model = _ProxiedChatOpenAI(\n model=\"openai/gpt-5.5\",\n base_url=\"http://localhost:4000\",\n api_key=SecretStr(\"test-key\"),\n )\n with pytest.raises(ValueError, match=\"greater than 0\"):\n asyncio.run(model.ainvoke(\"hi\"))\n assert called is False, \"upstream request coroutine must not be created on bad timeout\"\n"}, "907ee9662255e33a64eb08735a87716bd9dc356d": {"t": 1785755387.4356441, "n": 61118, "h": "feadd47abd3f99ae", "c": "\"\"\"Unit tests for decepticon.llm.factory.\"\"\"\n\nimport asyncio\nimport logging\n\nimport pytest\n\nfrom decepticon.llm.factory import (\n LLMFactory,\n _is_real_key,\n _llamacpp_local_configured,\n _method_is_configured,\n _oauth_credentials_present,\n _oauth_env_credentials_present,\n _resolve_credentials,\n)\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\n\n\nclass TestIsRealKey:\n \"\"\"Vendor-aware API key validation.\n\n The launcher writes ``your-…-key-here`` placeholders into .env so the\n user can later swap in a real key. The factory needs to reject those\n plus any obvious junk (short strings, ``placeholder``/``not-used``\n markers, vendor-prefix mismatches) so the credentials inventory\n reflects what actually works at request time.\n \"\"\"\n\n def test_rejects_empty_and_placeholder_template(self) -> None:\n assert _is_real_key(\"\") is False\n assert _is_real_key(\" \") is False\n assert _is_real_key(\"your-anthropic-key-here\") is False\n assert _is_real_key(\"YOUR-OPENAI-KEY-HERE\") is False # case-insensitive\n\n def test_rejects_short_strings(self) -> None:\n # Under 24 chars — every vendor-issued key exceeds this.\n assert _is_real_key(\"sk-tooshort\") is False\n\n def test_rejects_placeholder_tokens_in_value(self) -> None:\n long_enough = \"x\" * 30\n for token in (\"placeholder\", \"not-used\", \"dummy\", \"fake\", \"example\"):\n assert _is_real_key(f\"sk-{token}-{long_enough}\") is False, token\n\n def test_accepts_realistic_keys_without_method(self) -> None:\n # Without method context, prefix check is skipped.\n assert _is_real_key(\"sk-ant-api03-realtokenfortestingauthrouting12345\") is True\n assert _is_real_key(\"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\") is True\n\n def test_rejects_wrong_vendor_prefix(self) -> None:\n # An OpenAI-shaped key in the Anthropic slot must be caught.\n openai_key = \"sk-proj-realopenaitokenfortestingauthrouting12345\"\n assert _is_real_key(openai_key, AuthMethod.ANTHROPIC_API) is False\n # …and vice versa.\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.GOOGLE_API) is False\n\n def test_accepts_correct_vendor_prefix(self) -> None:\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.ANTHROPIC_API) is True\n google_key = \"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\"\n assert _is_real_key(google_key, AuthMethod.GOOGLE_API) is True\n\n\nclass TestOAuthCredentialsPresent:\n \"\"\"OAuth detection requires the credential file alongside the boolean.\n\n Without the file check, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place OAuth in\n every fallback chain and generate one 401 per request.\n \"\"\"\n\n def test_returns_false_when_file_missing(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(tmp_path / \"absent.json\"))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_when_file_is_empty(self, monkeypatch, tmp_path) -> None:\n # ``/dev/null``-style mounts read as empty — must fail closed.\n empty = tmp_path / \"empty.json\"\n empty.write_text(\"\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(empty))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_on_invalid_json(self, monkeypatch, tmp_path) -> None:\n bad = tmp_path / \"bad.json\"\n bad.write_text(\"{not-json\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(bad))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_true_when_file_is_well_formed(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"credentials.json\"\n good.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeef\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is True\n\n def test_codex_path_via_env_override(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"auth.json\"\n good.write_text('{\"tokens\": {\"access_token\": \"ABC\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CODEX_AUTH_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.OPENAI_OAUTH) is True\n\n\nclass TestOAuthEnvCredentials:\n \"\"\"B12: env-backed OAuth creds count as configured, not just on-disk files.\n Before the fix, ``_method_is_configured`` required a credential *file* even\n though the LiteLLM handlers also accept env-var tokens (GEMINI_ACCESS_TOKEN,\n GROK_SESSION_TOKEN, COPILOT_REFRESH_TOKEN, …). Env-only setups were silently\n dropped from the fallback chain.\n \"\"\"\n\n def test_env_present_returns_true_for_each_method(self, monkeypatch) -> None:\n cases = {\n AuthMethod.GOOGLE_OAUTH: \"GEMINI_ACCESS_TOKEN\",\n AuthMethod.COPILOT_OAUTH: \"COPILOT_REFRESH_TOKEN\",\n AuthMethod.GROK_OAUTH: \"GROK_SESSION_TOKEN\",\n AuthMethod.PERPLEXITY_OAUTH: \"PERPLEXITY_SESSION_TOKEN\",\n AuthMethod.ANTHROPIC_OAUTH: \"ANTHROPIC_OAUTH_TOKEN\",\n }\n for method, env_var in cases.items():\n monkeypatch.setenv(env_var, \"tok-from-env\")\n assert _oauth_env_credentials_present(method) is True\n monkeypatch.delenv(env_var, raising=False)\n\n def test_env_only_method_is_configured_without_file(self, monkeypatch, tmp_path) -> None:\n # Flag true + env token, but the credential file is absent.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.setenv(\"DECEPTICON_AUTH_GEMINI\", \"true\")\n monkeypatch.setenv(\"GEMINI_ACCESS_TOKEN\", \"ya29.env-token\")\n assert _oauth_credentials_present(AuthMethod.GOOGLE_OAUTH) is False\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is True\n\n def test_method_not_configured_without_file_or_env(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.setenv(\"DECEPTICON_AUTH_GEMINI\", \"true\")\n monkeypatch.delenv(\"GEMINI_ACCESS_TOKEN\", raising=False)\n monkeypatch.delenv(\"GEMINI_SESSION_COOKIES\", raising=False)\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is False\n\n def test_env_creds_still_require_intent_flag(self, monkeypatch, tmp_path) -> None:\n # Env token present but the boolean intent flag is off → not configured.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.delenv(\"DECEPTICON_AUTH_GEMINI\", raising=False)\n monkeypatch.setenv(\"GEMINI_ACCESS_TOKEN\", \"ya29.env-token\")\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is False\n\n def test_codex_has_no_env_token_override(self, monkeypatch) -> None:\n # Codex authenticates only from its file; no env-token override exists.\n monkeypatch.setenv(\"CODEX_ACCESS_TOKEN\", \"should-not-count\")\n assert _oauth_env_credentials_present(AuthMethod.OPENAI_OAUTH) is False\n\n\nclass TestLLMFactory:\n def setup_method(self):\n self.proxy = ProxyConfig(url=\"http://localhost:4000\", api_key=\"test-key\")\n # Build an explicit mapping so the test doesn't depend on env vars.\n creds = Credentials.all_api_methods()\n self.mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n self.factory = LLMFactory(self.proxy, self.mapping)\n\n def test_factory_initializes(self):\n assert self.factory.proxy_url == \"http://localhost:4000\"\n\n def test_get_model_returns_chat_model(self):\n model = self.factory.get_model(\"recon\")\n assert model is not None\n assert model.model_name == \"anthropic/claude-haiku-4-5\"\n\n def test_get_model_caches_instances(self):\n m1 = self.factory.get_model(\"recon\")\n m2 = self.factory.get_model(\"recon\")\n assert m1 is m2\n\n def test_get_model_different_roles_different_models(self):\n recon = self.factory.get_model(\"recon\")\n decepticon = self.factory.get_model(\"decepticon\")\n assert recon is not decepticon\n assert recon.model_name != decepticon.model_name\n\n def test_get_model_unknown_role_raises(self):\n with pytest.raises(KeyError, match=\"No model assignment\"):\n self.factory.get_model(\"nonexistent\")\n\n def test_router_accessible(self):\n assert self.factory.router is not None\n\n def test_get_fallback_models_full_chain(self):\n models = self.factory.get_fallback_models(\"recon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5-nano\",\n \"gemini/gemini-2.5-flash-lite\",\n \"deepseek/deepseek-v4-flash\",\n \"openrouter/anthropic/claude-haiku-4-5\",\n \"nvidia_nim/meta/llama-3.2-3b-instruct\",\n ]\n\n def test_get_fallback_models_high_tier_includes_all_methods(self):\n models = self.factory.get_fallback_models(\"decepticon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5.5\",\n \"gemini/gemini-2.5-pro\",\n \"minimax/MiniMax-M3\",\n \"deepseek/deepseek-v4-pro\",\n \"xai/grok-4.3\",\n \"mistral/mistral-large-latest\",\n \"openrouter/anthropic/claude-opus-4-8\",\n \"nvidia_nim/meta/llama-3.3-70b-instruct\",\n ]\n\n def test_get_fallback_models_without_fallback(self):\n # Single-credential mapping → no fallback.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n factory = LLMFactory(self.proxy, mapping)\n assert factory.get_fallback_models(\"recon\") == []\n\n def test_explicit_credentials_param(self):\n # Constructor accepts a Credentials object instead of a full mapping.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n factory = LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n assert factory.get_model(\"decepticon\").model_name == \"openai/gpt-5.5\"\n\n\nclass TestLLMFactoryEmptyChainValidation:\n \"\"\"A credentials inventory whose AuthMethods lack a (method, tier) entry\n for some required tier resolves to an EMPTY model chain at that tier.\n ``LLMModelMapping.from_credentials_and_profile`` silently skips such\n roles, so the failure surfaces much later — either as a confusing\n ``KeyError: No model assignment for role: ...`` at ``get_model`` time\n or, when a caller routes through middleware, as a primary-less\n ``ModelFallbackMiddleware([])``. Validate at construction so the\n operator gets one actionable error naming the empty tier(s) and the\n configured credentials inventory.\"\"\"\n\n def setup_method(self) -> None:\n self.proxy = ProxyConfig(url=\"http://localhost:4000\", api_key=\"test-key\")\n\n def test_empty_tier_raises_at_init_with_inventory_listed(self) -> None:\n # MINIMAX_API has only HIGH+MID entries in METHOD_MODELS — no LOW.\n # ECO routes ``recon`` (and other LOW roles) to Tier.LOW, so the\n # LOW chain resolves to []. The factory must refuse to construct.\n creds = Credentials(methods=[AuthMethod.MINIMAX_API])\n with pytest.raises(ValueError) as exc_info:\n LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n msg = str(exc_info.value)\n # Names the empty tier so the operator knows what to fix.\n assert \"low\" in msg.lower()\n # Echoes the configured credentials so the operator can audit\n # what was actually detected (vs. what they think they set).\n assert AuthMethod.MINIMAX_API.value in msg\n\n def test_test_profile_empty_high_chain_raises(self) -> None:\n # MINIMAX_API covers HIGH+MID but not LOW; TEST profile forces every\n # role to LOW → empty chain across the board.\n creds = Credentials(methods=[AuthMethod.MINIMAX_API])\n with pytest.raises(ValueError, match=\"(?i)low\"):\n LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.TEST)\n\n def test_valid_inventory_still_constructs(self) -> None:\n # OPENAI_API covers all three tiers — no empty chain, no regression.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n factory = LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n assert factory.get_model(\"recon\").model_name == \"openai/gpt-5-nano\"\n assert factory.get_model(\"decepticon\").model_name == \"openai/gpt-5.5\"\n\n def test_explicit_mapping_bypasses_validation(self) -> None:\n # When the caller supplies a mapping directly, they own its shape —\n # the credentials-based validation does not apply (existing tests\n # build empty/partial mappings explicitly).\n factory = LLMFactory(self.proxy, mapping=LLMModelMapping())\n assert factory.proxy_url == \"http://localhost:4000\"\n\n\nclass TestLLMFactoryHealthCheck:\n def test_health_check_returns_false_when_no_proxy(self):\n proxy = ProxyConfig(url=\"http://localhost:19999\")\n factory = LLMFactory(proxy, mapping=LLMModelMapping())\n assert asyncio.run(factory.health_check()) is False\n\n\nclass TestResolveCredentials:\n def test_real_keys_only(self, monkeypatch):\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"your-openai-key-here\") # placeholder\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_API]\n\n def test_oauth_only(self, monkeypatch, tmp_path):\n # OAuth detection requires the credential FILE alongside the\n # boolean — point Claude Code at a temp credentials file so the\n # test runs deterministically regardless of host state.\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_OAUTH]\n\n def test_oauth_flag_without_credential_file_is_dropped(self, monkeypatch, tmp_path):\n \"\"\"Stale ``DECEPTICON_AUTH_CLAUDE_CODE=true`` after ``codex logout``\n (or after the user deleted ``~/.claude/.credentials.json``) must\n not place the OAuth method into the chain — otherwise every\n request 401s before falling back to the next provider.\n \"\"\"\n # Point both the primary and legacy fallback paths at tmp_path so\n # any ``~/.claude/.credentials.json`` or\n # ``~/.config/anthropic/q/tokens.json`` on the dev host doesn't\n # accidentally satisfy the file-presence check.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n missing = tmp_path / \"missing.json\" # never created\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n creds = _resolve_credentials()\n assert AuthMethod.ANTHROPIC_OAUTH not in creds.methods\n\n def test_oauth_plus_api_priority_default(self, monkeypatch, tmp_path):\n # Default priority is anthropic_oauth > anthropic_api > openai_api ...\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n ]\n\n def test_explicit_priority_override(self, monkeypatch):\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"openai_api,anthropic_api\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n monkeypatch.delenv(\"GEMINI_API_KEY\", raising=False)\n monkeypatch.delenv(\"MINIMAX_API_KEY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OPENAI_API, AuthMethod.ANTHROPIC_API]\n\n def test_placeholder_falls_back_to_all_api_methods(self, monkeypatch):\n \"\"\"When every detected method is a placeholder/missing, the resolver\n falls back to the all-API-methods inventory so module-level agent\n constructors stay importable in CI / dev shells without keys.\"\"\"\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"your-anthropic-key-here\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n ]\n\n def test_explicit_priority_no_creds_logs_error(self, monkeypatch, tmp_path, caplog):\n \"\"\"An explicit ``DECEPTICON_AUTH_PRIORITY`` whose every listed\n method fails detection (typical of a broken OAuth credentials\n mount — e.g. ``CLAUDE_CREDENTIALS_VOLUME`` unset and the\n compose file bound ``/dev/null`` into the container) must\n surface the root cause at ERROR level.\n\n Otherwise the silent fallback to ``all_api_methods()`` runs\n through nine unrelated providers and the operator only sees a\n downstream 401-cascade — confusingly masked as a 429 once the\n routed-to provider (e.g. NVIDIA NIM) cools down. Backward\n compat is preserved: the resolver still returns\n ``all_api_methods()`` so module imports remain green; the\n real model call surfaces a separate, actionable error via\n ``_reraise_with_actionable_message``.\n \"\"\"\n # Point the OAuth credential path at a file we never create so\n # detection fails the same way ``/dev/null`` does at runtime.\n missing = tmp_path / \"missing.json\"\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"anthropic_oauth\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n # ``decepticon_core.utils.logging`` sets the parent decepticon logger\n # to ``propagate=False`` so library output doesn't double up in\n # apps that own their root handler. caplog hooks the root logger,\n # so without re-enabling propagation our ERROR record never\n # reaches pytest's capture buffer. Re-enable for the duration of\n # the call and let monkeypatch restore on teardown.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.ERROR):\n creds = _resolve_credentials()\n\n # Backward compat: still returns the all-API-methods fallback.\n assert creds.methods == Credentials.all_api_methods().methods\n\n # ERROR diagnostic must mention the env var name so an operator\n # scanning ``decepticon logs langgraph`` knows where to look.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert any(\"DECEPTICON_AUTH_PRIORITY\" in r.getMessage() for r in error_records), (\n f\"No ERROR log mentioned DECEPTICON_AUTH_PRIORITY. \"\n f\"Got: {[r.getMessage() for r in error_records]}\"\n )\n\n def test_implicit_priority_no_creds_stays_info_level(self, monkeypatch, caplog):\n \"\"\"Implicit priority (no DECEPTICON_AUTH_PRIORITY set) with no\n detectable credentials is the import-friendly CI/test path:\n we keep the existing INFO log and ``all_api_methods()`` return\n so module imports work without keys present. Only **explicit**\n priority with no matches gets escalated to ERROR.\n \"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n\n # Match the explicit-priority test's pattern so caplog sees the\n # records — the decepticon parent logger defaults to\n # ``propagate=False`` per ``decepticon_core.utils.logging``.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.DEBUG):\n creds = _resolve_credentials()\n\n assert creds.methods == Credentials.all_api_methods().methods\n # No ERROR records — this case stays informational.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert not error_records, (\n f\"Unexpected ERROR logs in implicit-priority path: \"\n f\"{[r.getMessage() for r in error_records]}\"\n )\n\n def test_ollama_local_only_returns_ollama_chain(self, monkeypatch):\n \"\"\"Issue #106: a user with only OLLAMA_API_BASE / OLLAMA_MODEL set\n (no API keys, no OAuth) must get a chain of one — Ollama only.\n Falling back to all-API-methods would produce 401 errors on every\n provider the user doesn't have.\"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL]\n\n def test_explicit_priority_with_ollama_local(self, monkeypatch):\n \"\"\"User opts into Ollama via explicit priority — the resolver\n recognizes it as configured when OLLAMA_API_BASE is set.\"\"\"\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"ollama_local,anthropic_api\")\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\n# ── Temperature handling (issue #107) ───────────────────────────────────\n\n\nclass TestTemperatureDrop:\n \"\"\"Claude Opus 4.7 rejects ``temperature`` regardless of route. The\n factory must drop it on every Opus 4 surface (anthropic/, auth/,\n openrouter/anthropic/) and keep it for everyone else.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _model_drops_temperature\n\n self._drops = _model_drops_temperature\n\n def test_anthropic_opus_drops_temperature(self):\n assert self._drops(\"anthropic/claude-opus-4-7\") is True\n\n def test_oauth_opus_drops_temperature(self):\n assert self._drops(\"auth/claude-opus-4-7\") is True\n\n def test_openrouter_opus_drops_temperature(self):\n assert self._drops(\"openrouter/anthropic/claude-opus-4-7\") is True\n\n def test_sonnet_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-sonnet-4-6\") is False\n\n def test_haiku_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-haiku-4-5\") is False\n\n def test_openai_keeps_temperature(self):\n assert self._drops(\"openai/gpt-5.5\") is False\n\n def test_ollama_keeps_temperature(self):\n assert self._drops(\"ollama_chat/qwen3-coder:30b\") is False\n\n\n# ── Actionable error translation (issue #107 + community feedback) ──────\n\n\nclass TestActionableErrorTranslation:\n \"\"\"The OSS user complaint: every upstream failure surfaces as 'An\n internal error occurred', stripping the message that would tell them\n what to fix. Each branch below verifies one class of error gets\n rewritten with a remediation hint and the model id that hit the\n failure.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _reraise_with_actionable_message\n\n self._translate = _reraise_with_actionable_message\n\n def test_no_fallback_model_group_branch(self):\n exc = Exception(\n \"litellm.BadRequestError: ... No fallback model group found \"\n \"for original model_group=anthropic/claude-opus-4-7.\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"no provider fallback\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_400_bad_request_branch(self):\n # openai.BadRequestError carries 'Error code: 400' in repr.\n exc = Exception(\"Error code: 400 - {'error': {'message': 'temperature is deprecated'}}\")\n type(exc).__name__ # noqa: B018 — sanity, ensures Exception default name\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n assert \"rejected the request (400)\" in str(info.value)\n\n def test_401_authentication_branch(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\"Error code: 401 - invalid_api_key\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"credentials (401)\" in msg\n assert \"decepticon onboard --reset\" in msg\n\n def test_429_ratelimit_branch(self):\n exc = type(\"RateLimitError\", (Exception,), {})(\"Error code: 429\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"rate limit (429)\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_404_notfound_with_ollama_hint(self):\n exc = type(\"NotFoundError\", (Exception,), {})(\"Error code: 404 - model not found\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"ollama_chat/nonexistent\")\n msg = str(info.value)\n assert \"404\" in msg\n assert \"OLLAMA_MODEL\" in msg\n\n def test_unmatched_error_passes_through(self):\n # Anything we don't recognize must NOT raise — the caller's\n # ``raise`` follows and re-raises the original exception with\n # full traceback.\n exc = ValueError(\"something completely unrelated\")\n # Should not raise from the helper.\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n\n def test_401_redacts_bearer_token_keeps_guidance(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\n \"Error code: 401 - {'error': 'invalid_api_key'} \"\n \"Authorization: Bearer sk-ant-SECRET123FAKEPLACEHOLDERTOKEN\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"sk-ant-SECRET123FAKEPLACEHOLDERTOKEN\" not in msg\n assert \"SECRET123FAKEPLACEHOLDERTOKEN\" not in msg\n assert \"[REDACTED]\" in msg\n assert \"credentials (401)\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n\n def test_400_redacts_api_key_kwarg_keeps_guidance(self):\n exc = Exception(\n \"Error code: 400 - bad request from provider with api_key=sk-SECRETfakeplaceholdervalue99\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"sk-SECRETfakeplaceholdervalue99\" not in msg\n assert \"SECRETfakeplaceholdervalue99\" not in msg\n assert \"[REDACTED]\" in msg\n assert \"rejected the request (400)\" in msg\n\n def test_redact_secrets_preserves_nonsecret_text(self):\n from decepticon.llm.factory import _redact_secrets\n\n text = \"No fallback model group found for model_group=anthropic/claude-opus-4-7.\"\n assert _redact_secrets(text) == text\n\n\n# ── DeepSeek V4 Pro reasoning_content passthrough ────────────────────────\n\n\nclass TestDeepSeekReasoningContent:\n \"\"\"Verify reasoning_content survives both the non-streaming and streaming\n paths so it can be sent back on subsequent API turns.\"\"\"\n\n def test_create_chat_result_extracts_reasoning_content(self):\n \"\"\"Non-streaming: _create_chat_result captures reasoning_content\n from the response dict's choice message.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Simulate the OpenAI response dict with reasoning_content\n response_dict = {\n \"choices\": [\n {\n \"message\": {\n \"role\": \"assistant\",\n \"content\": \"Four\",\n \"reasoning_content\": \"I think therefore I am\",\n },\n \"finish_reason\": \"stop\",\n }\n ],\n \"usage\": {},\n }\n\n # Mock the parent's _create_chat_result to return a ChatResult without reasoning_content\n msg = AIMessage(content=\"Four\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert (\n result.generations[0].message.additional_kwargs[\"reasoning_content\"]\n == \"I think therefore I am\"\n )\n\n def test_create_chat_result_skips_when_absent(self):\n \"\"\"Non-streaming: no crash when reasoning_content is missing.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n response_dict = {\n \"choices\": [\n {\"message\": {\"role\": \"assistant\", \"content\": \"hi\"}, \"finish_reason\": \"stop\"}\n ],\n \"usage\": {},\n }\n\n msg = AIMessage(content=\"hi\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert \"reasoning_content\" not in result.generations[0].message.additional_kwargs\n\n def test_convert_chunk_injects_reasoning_content(self):\n \"\"\"Streaming: _convert_chunk_to_generation_chunk captures\n reasoning_content from the raw delta and injects it into\n AIMessageChunk.additional_kwargs.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Build a fake raw SSE chunk dict with reasoning_content in the delta\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Let me think...\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n # The parent's _convert_chunk_to_generation_chunk builds the\n # ChatGenerationChunk but ignores reasoning_content. We mock it\n # to return a chunk without reasoning_content, then verify our\n # override injects it.\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n # Bind the real method to our mock instance\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert result.message.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n\n def test_convert_chunk_no_reasoning_leaves_kwargs_clean(self):\n \"\"\"Streaming: when delta has no reasoning_content, additional_kwargs\n is not polluted.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\"role\": \"assistant\", \"content\": \"hi\"},\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_reasoning_content_accumulates_across_chunks(self):\n \"\"\"Streaming: reasoning_content from multiple chunks concatenates\n via AIMessageChunk.__add__ (merge_dicts).\"\"\"\n from langchain_core.messages import AIMessageChunk\n\n c1 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"Let me \"})\n c2 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"think...\"})\n c3 = AIMessageChunk(content=\"Hello!\", additional_kwargs={})\n\n merged = c1 + c2 + c3\n assert merged.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n assert merged.content == \"Hello!\"\n\n def test_get_request_payload_injects_reasoning_content(self):\n \"\"\"Outbound: _get_request_payload injects reasoning_content from\n AIMessage.additional_kwargs into serialized assistant message dicts.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"hi\"),\n AIMessage(\n content=\"hello\",\n additional_kwargs={\"reasoning_content\": \"thinking...\"},\n ),\n ]\n\n # Mock super()._get_request_payload to return a payload without reasoning_content\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"hi\"},\n {\"role\": \"assistant\", \"content\": \"hello\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thinking...\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert payload[\"extra_body\"][\"thinking\"] == {\"type\": \"enabled\"}\n assert payload[\"reasoning_effort\"] == \"high\"\n\n def test_convert_chunk_empty_reasoning_content_ignored(self):\n \"\"\"Streaming: empty string reasoning_content in delta is ignored.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"hi\",\n \"reasoning_content\": \"\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n # Empty string is falsy — should not pollute additional_kwargs\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_none_delta_no_crash(self):\n \"\"\"Streaming: None delta in choices does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": None,\n \"finish_reason\": \"stop\",\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_empty_choices_no_crash(self):\n \"\"\"Streaming: empty choices array does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"choices\": []}\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_parent_returns_none(self):\n \"\"\"Streaming: when parent returns None, we return None too.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"type\": \"content.delta\"} # parent returns None for these\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=None,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is None\n\n def test_convert_chunk_beta_stream_format(self):\n \"\"\"Streaming: handles beta.chat.completions.stream nested chunk format.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Some LangChain versions nest under \"chunk\" key\n raw_chunk = {\n \"chunk\": {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Thinking via beta format...\",\n },\n \"finish_reason\": None,\n }\n ],\n },\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert (\n result.message.additional_kwargs[\"reasoning_content\"] == \"Thinking via beta format...\"\n )\n\n def test_get_request_payload_multiple_assistant_messages(self):\n \"\"\"Outbound: each assistant message gets its own reasoning_content.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"q1\"),\n AIMessage(content=\"a1\", additional_kwargs={\"reasoning_content\": \"thought1\"}),\n HumanMessage(content=\"q2\"),\n AIMessage(content=\"a2\", additional_kwargs={\"reasoning_content\": \"thought2\"}),\n ]\n\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"q1\"},\n {\"role\": \"assistant\", \"content\": \"a1\"},\n {\"role\": \"user\", \"content\": \"q2\"},\n {\"role\": \"assistant\", \"content\": \"a2\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thought1\"\n assert payload[\"messages\"][3][\"reasoning_content\"] == \"thought2\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert \"reasoning_content\" not in payload[\"messages\"][2]\n\n def test_model_detection(self):\n \"\"\"Factory routes DeepSeek V4 (pro + flash) and legacy reasoner.\n\n Per DeepSeek's API docs ``deepseek-reasoner`` is the deprecated\n alias for ``deepseek-v4-flash`` thinking mode, so v4-flash also\n returns ``reasoning_content`` and the API rejects subsequent\n tool turns when the field is omitted. Closes #201, #220.\n \"\"\"\n from decepticon.llm.factory import _model_is_deepseek_thinking\n\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-pro\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-flash\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-reasoner\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-chat\") is False\n assert _model_is_deepseek_thinking(\"openai/gpt-5.5\") is False\n\n\n# ── LLAMACPP_LOCAL credential detection (issue #151) ────────────────────\n\n\n_LLAMACPP_RELATED_ENV = (\n \"LLAMACPP_API_BASE\",\n \"LLAMACPP_MODEL\",\n \"LLAMACPP_API_KEY\",\n)\n\n\ndef _scrub_other_credentials(monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"Delete any env that would be detected as another credential.\n\n The credential-detection tests below need to assert \"only LLAMACPP\n is detected\"; without this scrub, a developer's exported\n ``ANTHROPIC_API_KEY`` would creep in and the assertion would fail\n locally only.\n \"\"\"\n for var in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"GROQ_API_KEY\",\n \"TOGETHER_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"COHERE_API_KEY\",\n \"MOONSHOT_API_KEY\",\n \"ZAI_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"GITHUB_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n ):\n monkeypatch.delenv(var, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n\nclass TestLlamacppLocalConfigured:\n \"\"\"``_llamacpp_local_configured`` is the gate for adding\n ``LLAMACPP_LOCAL`` to the credentials chain. Either env var being\n set is enough — neither requires the other, mirroring the\n LM Studio / Ollama detection contract.\n \"\"\"\n\n def test_returns_false_when_neither_env_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n assert _llamacpp_local_configured() is False\n\n def test_returns_true_when_only_base_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n assert _llamacpp_local_configured() is True\n\n def test_returns_true_when_only_model_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n assert _llamacpp_local_configured() is True\n\n def test_whitespace_does_not_count_as_set(self, monkeypatch):\n \"\"\"Stray ``LLAMACPP_API_BASE=`` lines in .env must not silently\n opt the user in — same fail-soft as the Ollama detector.\"\"\"\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \" \")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"\")\n assert _llamacpp_local_configured() is False\n\n\nclass TestResolveCredentialsForLlamacpp:\n \"\"\"End-to-end check that ``_resolve_credentials`` picks up the\n user's llama.cpp config — both the explicit-priority path and the\n \"only LLAMACPP_* detected\" auto-fallback at the bottom of\n ``_resolve_credentials``.\n \"\"\"\n\n def test_only_llamacpp_detected_returns_llamacpp_only_chain(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL]\n\n def test_priority_list_with_llamacpp_picks_it_up(self, monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"User wrote an explicit DECEPTICON_AUTH_PRIORITY listing\n llamacpp_local — the resolver must respect that ordering.\"\"\"\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen-coder\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"llamacpp_local,anthropic_api\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\nclass TestLLMTimeout:\n \"\"\"Whole-coroutine LLM request timeout (DECEPTICON_LLM_TIMEOUT_SECONDS).\"\"\"\n\n @pytest.fixture(autouse=True)\n def _isolate_timeout_env(self, monkeypatch: pytest.MonkeyPatch) -> None:\n monkeypatch.delenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_LLM__TIMEOUT\", raising=False)\n\n def test_call_with_timeout_raises_typed_exception(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> str:\n await asyncio.sleep(10)\n return \"done\"\n\n with pytest.raises(LLMTimeoutError, match=\"timed out after 0.01 seconds\"):\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n\n def test_call_with_timeout_preserves_original_timeout_as_cause(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> None:\n await asyncio.sleep(10)\n\n try:\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n except LLMTimeoutError as exc:\n assert isinstance(exc.__cause__, asyncio.TimeoutError)\n else:\n raise AssertionError(\"expected LLMTimeoutError\")\n\n def test_call_with_timeout_returns_successful_result(self) -> None:\n from decepticon.llm.factory import call_with_timeout\n\n async def fast_call() -> str:\n await asyncio.sleep(0)\n return \"ok\"\n\n assert asyncio.run(call_with_timeout(fast_call(), 1)) == \"ok\"\n\n def test_call_with_timeout_propagates_non_timeout_exceptions(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def failing_call() -> None:\n raise RuntimeError(\"upstream 500\")\n\n with pytest.raises(RuntimeError, match=\"upstream 500\"):\n asyncio.run(call_with_timeout(failing_call(), 1))\n # And not as a timeout error\n try:\n asyncio.run(call_with_timeout(failing_call(), 1))\n except LLMTimeoutError:\n raise AssertionError(\"non-timeout exception was retyped\")\n except RuntimeError:\n pass\n\n def test_env_override_takes_effect(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"3\")\n assert _resolve_llm_timeout_seconds() == 3.0\n\n def test_default_timeout_is_600_when_env_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.delenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", raising=False)\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_blank_env_falls_back_to_default(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \" \")\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_invalid_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"not-a-number\")\n with pytest.raises(ValueError):\n _resolve_llm_timeout_seconds()\n\n def test_non_positive_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"0\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"-5\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n def test_pydantic_nested_alias_takes_effect(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"900\")\n assert _resolve_llm_timeout_seconds() == 900.0\n\n def test_explicit_env_wins_over_pydantic_alias(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"120\")\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"900\")\n assert _resolve_llm_timeout_seconds() == 120.0\n\n def test_alias_invalid_value_attributes_error_to_alias(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"nonsense\")\n with pytest.raises(ValueError, match=\"DECEPTICON_LLM__TIMEOUT\"):\n _resolve_llm_timeout_seconds()\n\n def test_alias_non_positive_attributes_error_to_alias(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"0\")\n with pytest.raises(ValueError, match=\"DECEPTICON_LLM__TIMEOUT.*greater than 0\"):\n _resolve_llm_timeout_seconds()\n"}, "b5fa553b69b68203fe69c30f33b44a6b6cc4a6a0": {"t": 1785755389.1686714, "n": 61118, "h": "f3aa58531fbc9110", "c": "\"\"\"Unit tests for decepticon.llm.factory.\"\"\"\n\nimport asyncio\nimport logging\n\nimport pytest\n\nfrom decepticon.llm.factory import (\n LLMFactory,\n _is_real_key,\n _llamacpp_local_configured,\n _method_is_configured,\n _oauth_credentials_present,\n _oauth_env_credentials_present,\n _resolve_credentials,\n)\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\n\n\nclass TestIsRealKey:\n \"\"\"Vendor-aware API key validation.\n\n The launcher writes ``your-…-key-here`` placeholders into .env so the\n user can later swap in a real key. The factory needs to reject those\n plus any obvious junk (short strings, ``placeholder``/``not-used``\n markers, vendor-prefix mismatches) so the credentials inventory\n reflects what actually works at request time.\n \"\"\"\n\n def test_rejects_empty_and_placeholder_template(self) -> None:\n assert _is_real_key(\"\") is False\n assert _is_real_key(\" \") is False\n assert _is_real_key(\"your-anthropic-key-here\") is False\n assert _is_real_key(\"YOUR-OPENAI-KEY-HERE\") is False # case-insensitive\n\n def test_rejects_short_strings(self) -> None:\n # Under 24 chars — every vendor-issued key exceeds this.\n assert _is_real_key(\"sk-tooshort\") is False\n\n def test_rejects_placeholder_tokens_in_value(self) -> None:\n long_enough = \"x\" * 30\n for token in (\"placeholder\", \"not-used\", \"dummy\", \"fake\", \"example\"):\n assert _is_real_key(f\"sk-{token}-{long_enough}\") is False, token\n\n def test_accepts_realistic_keys_without_method(self) -> None:\n # Without method context, prefix check is skipped.\n assert _is_real_key(\"sk-ant-api03-realtokenfortestingauthrouting12345\") is True\n assert _is_real_key(\"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\") is True\n\n def test_rejects_wrong_vendor_prefix(self) -> None:\n # An OpenAI-shaped key in the Anthropic slot must be caught.\n openai_key = \"sk-proj-realopenaitokenfortestingauthrouting12345\"\n assert _is_real_key(openai_key, AuthMethod.ANTHROPIC_API) is False\n # …and vice versa.\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.GOOGLE_API) is False\n\n def test_accepts_correct_vendor_prefix(self) -> None:\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.ANTHROPIC_API) is True\n google_key = \"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\"\n assert _is_real_key(google_key, AuthMethod.GOOGLE_API) is True\n\n\nclass TestOAuthCredentialsPresent:\n \"\"\"OAuth detection requires the credential file alongside the boolean.\n\n Without the file check, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place OAuth in\n every fallback chain and generate one 401 per request.\n \"\"\"\n\n def test_returns_false_when_file_missing(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(tmp_path / \"absent.json\"))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_when_file_is_empty(self, monkeypatch, tmp_path) -> None:\n # ``/dev/null``-style mounts read as empty — must fail closed.\n empty = tmp_path / \"empty.json\"\n empty.write_text(\"\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(empty))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_on_invalid_json(self, monkeypatch, tmp_path) -> None:\n bad = tmp_path / \"bad.json\"\n bad.write_text(\"{not-json\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(bad))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_true_when_file_is_well_formed(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"credentials.json\"\n good.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeef\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is True\n\n def test_codex_path_via_env_override(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"auth.json\"\n good.write_text('{\"tokens\": {\"access_token\": \"ABC\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CODEX_AUTH_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.OPENAI_OAUTH) is True\n\n\nclass TestOAuthEnvCredentials:\n \"\"\"B12: env-backed OAuth creds count as configured, not just on-disk files.\n Before the fix, ``_method_is_configured`` required a credential *file* even\n though the LiteLLM handlers also accept env-var tokens (GEMINI_ACCESS_TOKEN,\n GROK_SESSION_TOKEN, COPILOT_REFRESH_TOKEN, …). Env-only setups were silently\n dropped from the fallback chain.\n \"\"\"\n\n def test_env_present_returns_true_for_each_method(self, monkeypatch) -> None:\n cases = {\n AuthMethod.GOOGLE_OAUTH: \"GEMINI_ACCESS_TOKEN\",\n AuthMethod.COPILOT_OAUTH: \"COPILOT_REFRESH_TOKEN\",\n AuthMethod.GROK_OAUTH: \"GROK_SESSION_TOKEN\",\n AuthMethod.PERPLEXITY_OAUTH: \"PERPLEXITY_SESSION_TOKEN\",\n AuthMethod.ANTHROPIC_OAUTH: \"ANTHROPIC_OAUTH_TOKEN\",\n }\n for method, env_var in cases.items():\n monkeypatch.setenv(env_var, \"tok-from-env\")\n assert _oauth_env_credentials_present(method) is True\n monkeypatch.delenv(env_var, raising=False)\n\n def test_env_only_method_is_configured_without_file(self, monkeypatch, tmp_path) -> None:\n # Flag true + env token, but the credential file is absent.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.setenv(\"DECEPTICON_AUTH_GEMINI\", \"true\")\n monkeypatch.setenv(\"GEMINI_ACCESS_TOKEN\", \"ya29.env-token\")\n assert _oauth_credentials_present(AuthMethod.GOOGLE_OAUTH) is False\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is True\n\n def test_method_not_configured_without_file_or_env(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.setenv(\"DECEPTICON_AUTH_GEMINI\", \"true\")\n monkeypatch.delenv(\"GEMINI_ACCESS_TOKEN\", raising=False)\n monkeypatch.delenv(\"GEMINI_SESSION_COOKIES\", raising=False)\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is False\n\n def test_env_creds_still_require_intent_flag(self, monkeypatch, tmp_path) -> None:\n # Env token present but the boolean intent flag is off → not configured.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.delenv(\"DECEPTICON_AUTH_GEMINI\", raising=False)\n monkeypatch.setenv(\"GEMINI_ACCESS_TOKEN\", \"ya29.env-token\")\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is False\n\n def test_codex_has_no_env_token_override(self, monkeypatch) -> None:\n # Codex authenticates only from its file; no env-token override exists.\n monkeypatch.setenv(\"CODEX_ACCESS_TOKEN\", \"should-not-count\")\n assert _oauth_env_credentials_present(AuthMethod.OPENAI_OAUTH) is False\n\n\nclass TestLLMFactory:\n def setup_method(self):\n self.proxy = ProxyConfig(url=\"http://localhost:4000\", api_key=\"test-key\")\n # Build an explicit mapping so the test doesn't depend on env vars.\n creds = Credentials.all_api_methods()\n self.mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n self.factory = LLMFactory(self.proxy, self.mapping)\n\n def test_factory_initializes(self):\n assert self.factory.proxy_url == \"http://localhost:4000\"\n\n def test_get_model_returns_chat_model(self):\n model = self.factory.get_model(\"recon\")\n assert model is not None\n assert model.model_name == \"anthropic/claude-haiku-4-5\"\n\n def test_get_model_caches_instances(self):\n m1 = self.factory.get_model(\"recon\")\n m2 = self.factory.get_model(\"recon\")\n assert m1 is m2\n\n def test_get_model_different_roles_different_models(self):\n recon = self.factory.get_model(\"recon\")\n decepticon = self.factory.get_model(\"decepticon\")\n assert recon is not decepticon\n assert recon.model_name != decepticon.model_name\n\n def test_get_model_unknown_role_raises(self):\n with pytest.raises(KeyError, match=\"No model assignment\"):\n self.factory.get_model(\"nonexistent\")\n\n def test_router_accessible(self):\n assert self.factory.router is not None\n\n def test_get_fallback_models_full_chain(self):\n models = self.factory.get_fallback_models(\"recon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5-nano\",\n \"gemini/gemini-2.5-flash-lite\",\n \"deepseek/deepseek-v4-flash\",\n \"openrouter/anthropic/claude-haiku-4-5\",\n \"nvidia_nim/meta/llama-3.2-3b-instruct\",\n ]\n\n def test_get_fallback_models_high_tier_includes_all_methods(self):\n models = self.factory.get_fallback_models(\"decepticon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5.5\",\n \"gemini/gemini-2.5-pro\",\n \"minimax/MiniMax-M3\",\n \"deepseek/deepseek-v4-pro\",\n \"xai/grok-4.3\",\n \"mistral/mistral-large-latest\",\n \"openrouter/anthropic/claude-opus-4-7\",\n \"nvidia_nim/meta/llama-3.3-70b-instruct\",\n ]\n\n def test_get_fallback_models_without_fallback(self):\n # Single-credential mapping → no fallback.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n factory = LLMFactory(self.proxy, mapping)\n assert factory.get_fallback_models(\"recon\") == []\n\n def test_explicit_credentials_param(self):\n # Constructor accepts a Credentials object instead of a full mapping.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n factory = LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n assert factory.get_model(\"decepticon\").model_name == \"openai/gpt-5.5\"\n\n\nclass TestLLMFactoryEmptyChainValidation:\n \"\"\"A credentials inventory whose AuthMethods lack a (method, tier) entry\n for some required tier resolves to an EMPTY model chain at that tier.\n ``LLMModelMapping.from_credentials_and_profile`` silently skips such\n roles, so the failure surfaces much later — either as a confusing\n ``KeyError: No model assignment for role: ...`` at ``get_model`` time\n or, when a caller routes through middleware, as a primary-less\n ``ModelFallbackMiddleware([])``. Validate at construction so the\n operator gets one actionable error naming the empty tier(s) and the\n configured credentials inventory.\"\"\"\n\n def setup_method(self) -> None:\n self.proxy = ProxyConfig(url=\"http://localhost:4000\", api_key=\"test-key\")\n\n def test_empty_tier_raises_at_init_with_inventory_listed(self) -> None:\n # MINIMAX_API has only HIGH+MID entries in METHOD_MODELS — no LOW.\n # ECO routes ``recon`` (and other LOW roles) to Tier.LOW, so the\n # LOW chain resolves to []. The factory must refuse to construct.\n creds = Credentials(methods=[AuthMethod.MINIMAX_API])\n with pytest.raises(ValueError) as exc_info:\n LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n msg = str(exc_info.value)\n # Names the empty tier so the operator knows what to fix.\n assert \"low\" in msg.lower()\n # Echoes the configured credentials so the operator can audit\n # what was actually detected (vs. what they think they set).\n assert AuthMethod.MINIMAX_API.value in msg\n\n def test_test_profile_empty_high_chain_raises(self) -> None:\n # MINIMAX_API covers HIGH+MID but not LOW; TEST profile forces every\n # role to LOW → empty chain across the board.\n creds = Credentials(methods=[AuthMethod.MINIMAX_API])\n with pytest.raises(ValueError, match=\"(?i)low\"):\n LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.TEST)\n\n def test_valid_inventory_still_constructs(self) -> None:\n # OPENAI_API covers all three tiers — no empty chain, no regression.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n factory = LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n assert factory.get_model(\"recon\").model_name == \"openai/gpt-5-nano\"\n assert factory.get_model(\"decepticon\").model_name == \"openai/gpt-5.5\"\n\n def test_explicit_mapping_bypasses_validation(self) -> None:\n # When the caller supplies a mapping directly, they own its shape —\n # the credentials-based validation does not apply (existing tests\n # build empty/partial mappings explicitly).\n factory = LLMFactory(self.proxy, mapping=LLMModelMapping())\n assert factory.proxy_url == \"http://localhost:4000\"\n\n\nclass TestLLMFactoryHealthCheck:\n def test_health_check_returns_false_when_no_proxy(self):\n proxy = ProxyConfig(url=\"http://localhost:19999\")\n factory = LLMFactory(proxy, mapping=LLMModelMapping())\n assert asyncio.run(factory.health_check()) is False\n\n\nclass TestResolveCredentials:\n def test_real_keys_only(self, monkeypatch):\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"your-openai-key-here\") # placeholder\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_API]\n\n def test_oauth_only(self, monkeypatch, tmp_path):\n # OAuth detection requires the credential FILE alongside the\n # boolean — point Claude Code at a temp credentials file so the\n # test runs deterministically regardless of host state.\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_OAUTH]\n\n def test_oauth_flag_without_credential_file_is_dropped(self, monkeypatch, tmp_path):\n \"\"\"Stale ``DECEPTICON_AUTH_CLAUDE_CODE=true`` after ``codex logout``\n (or after the user deleted ``~/.claude/.credentials.json``) must\n not place the OAuth method into the chain — otherwise every\n request 401s before falling back to the next provider.\n \"\"\"\n # Point both the primary and legacy fallback paths at tmp_path so\n # any ``~/.claude/.credentials.json`` or\n # ``~/.config/anthropic/q/tokens.json`` on the dev host doesn't\n # accidentally satisfy the file-presence check.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n missing = tmp_path / \"missing.json\" # never created\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n creds = _resolve_credentials()\n assert AuthMethod.ANTHROPIC_OAUTH not in creds.methods\n\n def test_oauth_plus_api_priority_default(self, monkeypatch, tmp_path):\n # Default priority is anthropic_oauth > anthropic_api > openai_api ...\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n ]\n\n def test_explicit_priority_override(self, monkeypatch):\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"openai_api,anthropic_api\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n monkeypatch.delenv(\"GEMINI_API_KEY\", raising=False)\n monkeypatch.delenv(\"MINIMAX_API_KEY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OPENAI_API, AuthMethod.ANTHROPIC_API]\n\n def test_placeholder_falls_back_to_all_api_methods(self, monkeypatch):\n \"\"\"When every detected method is a placeholder/missing, the resolver\n falls back to the all-API-methods inventory so module-level agent\n constructors stay importable in CI / dev shells without keys.\"\"\"\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"your-anthropic-key-here\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n ]\n\n def test_explicit_priority_no_creds_logs_error(self, monkeypatch, tmp_path, caplog):\n \"\"\"An explicit ``DECEPTICON_AUTH_PRIORITY`` whose every listed\n method fails detection (typical of a broken OAuth credentials\n mount — e.g. ``CLAUDE_CREDENTIALS_VOLUME`` unset and the\n compose file bound ``/dev/null`` into the container) must\n surface the root cause at ERROR level.\n\n Otherwise the silent fallback to ``all_api_methods()`` runs\n through nine unrelated providers and the operator only sees a\n downstream 401-cascade — confusingly masked as a 429 once the\n routed-to provider (e.g. NVIDIA NIM) cools down. Backward\n compat is preserved: the resolver still returns\n ``all_api_methods()`` so module imports remain green; the\n real model call surfaces a separate, actionable error via\n ``_reraise_with_actionable_message``.\n \"\"\"\n # Point the OAuth credential path at a file we never create so\n # detection fails the same way ``/dev/null`` does at runtime.\n missing = tmp_path / \"missing.json\"\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"anthropic_oauth\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n # ``decepticon_core.utils.logging`` sets the parent decepticon logger\n # to ``propagate=False`` so library output doesn't double up in\n # apps that own their root handler. caplog hooks the root logger,\n # so without re-enabling propagation our ERROR record never\n # reaches pytest's capture buffer. Re-enable for the duration of\n # the call and let monkeypatch restore on teardown.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.ERROR):\n creds = _resolve_credentials()\n\n # Backward compat: still returns the all-API-methods fallback.\n assert creds.methods == Credentials.all_api_methods().methods\n\n # ERROR diagnostic must mention the env var name so an operator\n # scanning ``decepticon logs langgraph`` knows where to look.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert any(\"DECEPTICON_AUTH_PRIORITY\" in r.getMessage() for r in error_records), (\n f\"No ERROR log mentioned DECEPTICON_AUTH_PRIORITY. \"\n f\"Got: {[r.getMessage() for r in error_records]}\"\n )\n\n def test_implicit_priority_no_creds_stays_info_level(self, monkeypatch, caplog):\n \"\"\"Implicit priority (no DECEPTICON_AUTH_PRIORITY set) with no\n detectable credentials is the import-friendly CI/test path:\n we keep the existing INFO log and ``all_api_methods()`` return\n so module imports work without keys present. Only **explicit**\n priority with no matches gets escalated to ERROR.\n \"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n\n # Match the explicit-priority test's pattern so caplog sees the\n # records — the decepticon parent logger defaults to\n # ``propagate=False`` per ``decepticon_core.utils.logging``.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.DEBUG):\n creds = _resolve_credentials()\n\n assert creds.methods == Credentials.all_api_methods().methods\n # No ERROR records — this case stays informational.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert not error_records, (\n f\"Unexpected ERROR logs in implicit-priority path: \"\n f\"{[r.getMessage() for r in error_records]}\"\n )\n\n def test_ollama_local_only_returns_ollama_chain(self, monkeypatch):\n \"\"\"Issue #106: a user with only OLLAMA_API_BASE / OLLAMA_MODEL set\n (no API keys, no OAuth) must get a chain of one — Ollama only.\n Falling back to all-API-methods would produce 401 errors on every\n provider the user doesn't have.\"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL]\n\n def test_explicit_priority_with_ollama_local(self, monkeypatch):\n \"\"\"User opts into Ollama via explicit priority — the resolver\n recognizes it as configured when OLLAMA_API_BASE is set.\"\"\"\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"ollama_local,anthropic_api\")\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\n# ── Temperature handling (issue #107) ───────────────────────────────────\n\n\nclass TestTemperatureDrop:\n \"\"\"Claude Opus 4.7 rejects ``temperature`` regardless of route. The\n factory must drop it on every Opus 4 surface (anthropic/, auth/,\n openrouter/anthropic/) and keep it for everyone else.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _model_drops_temperature\n\n self._drops = _model_drops_temperature\n\n def test_anthropic_opus_drops_temperature(self):\n assert self._drops(\"anthropic/claude-opus-4-7\") is True\n\n def test_oauth_opus_drops_temperature(self):\n assert self._drops(\"auth/claude-opus-4-7\") is True\n\n def test_openrouter_opus_drops_temperature(self):\n assert self._drops(\"openrouter/anthropic/claude-opus-4-7\") is True\n\n def test_sonnet_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-sonnet-4-6\") is False\n\n def test_haiku_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-haiku-4-5\") is False\n\n def test_openai_keeps_temperature(self):\n assert self._drops(\"openai/gpt-5.5\") is False\n\n def test_ollama_keeps_temperature(self):\n assert self._drops(\"ollama_chat/qwen3-coder:30b\") is False\n\n\n# ── Actionable error translation (issue #107 + community feedback) ──────\n\n\nclass TestActionableErrorTranslation:\n \"\"\"The OSS user complaint: every upstream failure surfaces as 'An\n internal error occurred', stripping the message that would tell them\n what to fix. Each branch below verifies one class of error gets\n rewritten with a remediation hint and the model id that hit the\n failure.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _reraise_with_actionable_message\n\n self._translate = _reraise_with_actionable_message\n\n def test_no_fallback_model_group_branch(self):\n exc = Exception(\n \"litellm.BadRequestError: ... No fallback model group found \"\n \"for original model_group=anthropic/claude-opus-4-7.\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"no provider fallback\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_400_bad_request_branch(self):\n # openai.BadRequestError carries 'Error code: 400' in repr.\n exc = Exception(\"Error code: 400 - {'error': {'message': 'temperature is deprecated'}}\")\n type(exc).__name__ # noqa: B018 — sanity, ensures Exception default name\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n assert \"rejected the request (400)\" in str(info.value)\n\n def test_401_authentication_branch(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\"Error code: 401 - invalid_api_key\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"credentials (401)\" in msg\n assert \"decepticon onboard --reset\" in msg\n\n def test_429_ratelimit_branch(self):\n exc = type(\"RateLimitError\", (Exception,), {})(\"Error code: 429\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"rate limit (429)\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_404_notfound_with_ollama_hint(self):\n exc = type(\"NotFoundError\", (Exception,), {})(\"Error code: 404 - model not found\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"ollama_chat/nonexistent\")\n msg = str(info.value)\n assert \"404\" in msg\n assert \"OLLAMA_MODEL\" in msg\n\n def test_unmatched_error_passes_through(self):\n # Anything we don't recognize must NOT raise — the caller's\n # ``raise`` follows and re-raises the original exception with\n # full traceback.\n exc = ValueError(\"something completely unrelated\")\n # Should not raise from the helper.\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n\n def test_401_redacts_bearer_token_keeps_guidance(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\n \"Error code: 401 - {'error': 'invalid_api_key'} \"\n \"Authorization: Bearer sk-ant-SECRET123FAKEPLACEHOLDERTOKEN\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"sk-ant-SECRET123FAKEPLACEHOLDERTOKEN\" not in msg\n assert \"SECRET123FAKEPLACEHOLDERTOKEN\" not in msg\n assert \"[REDACTED]\" in msg\n assert \"credentials (401)\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n\n def test_400_redacts_api_key_kwarg_keeps_guidance(self):\n exc = Exception(\n \"Error code: 400 - bad request from provider with api_key=sk-SECRETfakeplaceholdervalue99\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"sk-SECRETfakeplaceholdervalue99\" not in msg\n assert \"SECRETfakeplaceholdervalue99\" not in msg\n assert \"[REDACTED]\" in msg\n assert \"rejected the request (400)\" in msg\n\n def test_redact_secrets_preserves_nonsecret_text(self):\n from decepticon.llm.factory import _redact_secrets\n\n text = \"No fallback model group found for model_group=anthropic/claude-opus-4-7.\"\n assert _redact_secrets(text) == text\n\n\n# ── DeepSeek V4 Pro reasoning_content passthrough ────────────────────────\n\n\nclass TestDeepSeekReasoningContent:\n \"\"\"Verify reasoning_content survives both the non-streaming and streaming\n paths so it can be sent back on subsequent API turns.\"\"\"\n\n def test_create_chat_result_extracts_reasoning_content(self):\n \"\"\"Non-streaming: _create_chat_result captures reasoning_content\n from the response dict's choice message.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Simulate the OpenAI response dict with reasoning_content\n response_dict = {\n \"choices\": [\n {\n \"message\": {\n \"role\": \"assistant\",\n \"content\": \"Four\",\n \"reasoning_content\": \"I think therefore I am\",\n },\n \"finish_reason\": \"stop\",\n }\n ],\n \"usage\": {},\n }\n\n # Mock the parent's _create_chat_result to return a ChatResult without reasoning_content\n msg = AIMessage(content=\"Four\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert (\n result.generations[0].message.additional_kwargs[\"reasoning_content\"]\n == \"I think therefore I am\"\n )\n\n def test_create_chat_result_skips_when_absent(self):\n \"\"\"Non-streaming: no crash when reasoning_content is missing.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n response_dict = {\n \"choices\": [\n {\"message\": {\"role\": \"assistant\", \"content\": \"hi\"}, \"finish_reason\": \"stop\"}\n ],\n \"usage\": {},\n }\n\n msg = AIMessage(content=\"hi\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert \"reasoning_content\" not in result.generations[0].message.additional_kwargs\n\n def test_convert_chunk_injects_reasoning_content(self):\n \"\"\"Streaming: _convert_chunk_to_generation_chunk captures\n reasoning_content from the raw delta and injects it into\n AIMessageChunk.additional_kwargs.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Build a fake raw SSE chunk dict with reasoning_content in the delta\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Let me think...\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n # The parent's _convert_chunk_to_generation_chunk builds the\n # ChatGenerationChunk but ignores reasoning_content. We mock it\n # to return a chunk without reasoning_content, then verify our\n # override injects it.\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n # Bind the real method to our mock instance\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert result.message.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n\n def test_convert_chunk_no_reasoning_leaves_kwargs_clean(self):\n \"\"\"Streaming: when delta has no reasoning_content, additional_kwargs\n is not polluted.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\"role\": \"assistant\", \"content\": \"hi\"},\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_reasoning_content_accumulates_across_chunks(self):\n \"\"\"Streaming: reasoning_content from multiple chunks concatenates\n via AIMessageChunk.__add__ (merge_dicts).\"\"\"\n from langchain_core.messages import AIMessageChunk\n\n c1 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"Let me \"})\n c2 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"think...\"})\n c3 = AIMessageChunk(content=\"Hello!\", additional_kwargs={})\n\n merged = c1 + c2 + c3\n assert merged.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n assert merged.content == \"Hello!\"\n\n def test_get_request_payload_injects_reasoning_content(self):\n \"\"\"Outbound: _get_request_payload injects reasoning_content from\n AIMessage.additional_kwargs into serialized assistant message dicts.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"hi\"),\n AIMessage(\n content=\"hello\",\n additional_kwargs={\"reasoning_content\": \"thinking...\"},\n ),\n ]\n\n # Mock super()._get_request_payload to return a payload without reasoning_content\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"hi\"},\n {\"role\": \"assistant\", \"content\": \"hello\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thinking...\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert payload[\"extra_body\"][\"thinking\"] == {\"type\": \"enabled\"}\n assert payload[\"reasoning_effort\"] == \"high\"\n\n def test_convert_chunk_empty_reasoning_content_ignored(self):\n \"\"\"Streaming: empty string reasoning_content in delta is ignored.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"hi\",\n \"reasoning_content\": \"\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n # Empty string is falsy — should not pollute additional_kwargs\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_none_delta_no_crash(self):\n \"\"\"Streaming: None delta in choices does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": None,\n \"finish_reason\": \"stop\",\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_empty_choices_no_crash(self):\n \"\"\"Streaming: empty choices array does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"choices\": []}\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_parent_returns_none(self):\n \"\"\"Streaming: when parent returns None, we return None too.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"type\": \"content.delta\"} # parent returns None for these\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=None,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is None\n\n def test_convert_chunk_beta_stream_format(self):\n \"\"\"Streaming: handles beta.chat.completions.stream nested chunk format.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Some LangChain versions nest under \"chunk\" key\n raw_chunk = {\n \"chunk\": {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Thinking via beta format...\",\n },\n \"finish_reason\": None,\n }\n ],\n },\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert (\n result.message.additional_kwargs[\"reasoning_content\"] == \"Thinking via beta format...\"\n )\n\n def test_get_request_payload_multiple_assistant_messages(self):\n \"\"\"Outbound: each assistant message gets its own reasoning_content.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"q1\"),\n AIMessage(content=\"a1\", additional_kwargs={\"reasoning_content\": \"thought1\"}),\n HumanMessage(content=\"q2\"),\n AIMessage(content=\"a2\", additional_kwargs={\"reasoning_content\": \"thought2\"}),\n ]\n\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"q1\"},\n {\"role\": \"assistant\", \"content\": \"a1\"},\n {\"role\": \"user\", \"content\": \"q2\"},\n {\"role\": \"assistant\", \"content\": \"a2\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thought1\"\n assert payload[\"messages\"][3][\"reasoning_content\"] == \"thought2\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert \"reasoning_content\" not in payload[\"messages\"][2]\n\n def test_model_detection(self):\n \"\"\"Factory routes DeepSeek V4 (pro + flash) and legacy reasoner.\n\n Per DeepSeek's API docs ``deepseek-reasoner`` is the deprecated\n alias for ``deepseek-v4-flash`` thinking mode, so v4-flash also\n returns ``reasoning_content`` and the API rejects subsequent\n tool turns when the field is omitted. Closes #201, #220.\n \"\"\"\n from decepticon.llm.factory import _model_is_deepseek_thinking\n\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-pro\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-flash\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-reasoner\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-chat\") is False\n assert _model_is_deepseek_thinking(\"openai/gpt-5.5\") is False\n\n\n# ── LLAMACPP_LOCAL credential detection (issue #151) ────────────────────\n\n\n_LLAMACPP_RELATED_ENV = (\n \"LLAMACPP_API_BASE\",\n \"LLAMACPP_MODEL\",\n \"LLAMACPP_API_KEY\",\n)\n\n\ndef _scrub_other_credentials(monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"Delete any env that would be detected as another credential.\n\n The credential-detection tests below need to assert \"only LLAMACPP\n is detected\"; without this scrub, a developer's exported\n ``ANTHROPIC_API_KEY`` would creep in and the assertion would fail\n locally only.\n \"\"\"\n for var in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"GROQ_API_KEY\",\n \"TOGETHER_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"COHERE_API_KEY\",\n \"MOONSHOT_API_KEY\",\n \"ZAI_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"GITHUB_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n ):\n monkeypatch.delenv(var, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n\nclass TestLlamacppLocalConfigured:\n \"\"\"``_llamacpp_local_configured`` is the gate for adding\n ``LLAMACPP_LOCAL`` to the credentials chain. Either env var being\n set is enough — neither requires the other, mirroring the\n LM Studio / Ollama detection contract.\n \"\"\"\n\n def test_returns_false_when_neither_env_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n assert _llamacpp_local_configured() is False\n\n def test_returns_true_when_only_base_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n assert _llamacpp_local_configured() is True\n\n def test_returns_true_when_only_model_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n assert _llamacpp_local_configured() is True\n\n def test_whitespace_does_not_count_as_set(self, monkeypatch):\n \"\"\"Stray ``LLAMACPP_API_BASE=`` lines in .env must not silently\n opt the user in — same fail-soft as the Ollama detector.\"\"\"\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \" \")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"\")\n assert _llamacpp_local_configured() is False\n\n\nclass TestResolveCredentialsForLlamacpp:\n \"\"\"End-to-end check that ``_resolve_credentials`` picks up the\n user's llama.cpp config — both the explicit-priority path and the\n \"only LLAMACPP_* detected\" auto-fallback at the bottom of\n ``_resolve_credentials``.\n \"\"\"\n\n def test_only_llamacpp_detected_returns_llamacpp_only_chain(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL]\n\n def test_priority_list_with_llamacpp_picks_it_up(self, monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"User wrote an explicit DECEPTICON_AUTH_PRIORITY listing\n llamacpp_local — the resolver must respect that ordering.\"\"\"\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen-coder\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"llamacpp_local,anthropic_api\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\nclass TestLLMTimeout:\n \"\"\"Whole-coroutine LLM request timeout (DECEPTICON_LLM_TIMEOUT_SECONDS).\"\"\"\n\n @pytest.fixture(autouse=True)\n def _isolate_timeout_env(self, monkeypatch: pytest.MonkeyPatch) -> None:\n monkeypatch.delenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_LLM__TIMEOUT\", raising=False)\n\n def test_call_with_timeout_raises_typed_exception(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> str:\n await asyncio.sleep(10)\n return \"done\"\n\n with pytest.raises(LLMTimeoutError, match=\"timed out after 0.01 seconds\"):\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n\n def test_call_with_timeout_preserves_original_timeout_as_cause(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> None:\n await asyncio.sleep(10)\n\n try:\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n except LLMTimeoutError as exc:\n assert isinstance(exc.__cause__, asyncio.TimeoutError)\n else:\n raise AssertionError(\"expected LLMTimeoutError\")\n\n def test_call_with_timeout_returns_successful_result(self) -> None:\n from decepticon.llm.factory import call_with_timeout\n\n async def fast_call() -> str:\n await asyncio.sleep(0)\n return \"ok\"\n\n assert asyncio.run(call_with_timeout(fast_call(), 1)) == \"ok\"\n\n def test_call_with_timeout_propagates_non_timeout_exceptions(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def failing_call() -> None:\n raise RuntimeError(\"upstream 500\")\n\n with pytest.raises(RuntimeError, match=\"upstream 500\"):\n asyncio.run(call_with_timeout(failing_call(), 1))\n # And not as a timeout error\n try:\n asyncio.run(call_with_timeout(failing_call(), 1))\n except LLMTimeoutError:\n raise AssertionError(\"non-timeout exception was retyped\")\n except RuntimeError:\n pass\n\n def test_env_override_takes_effect(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"3\")\n assert _resolve_llm_timeout_seconds() == 3.0\n\n def test_default_timeout_is_600_when_env_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.delenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", raising=False)\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_blank_env_falls_back_to_default(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \" \")\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_invalid_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"not-a-number\")\n with pytest.raises(ValueError):\n _resolve_llm_timeout_seconds()\n\n def test_non_positive_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"0\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"-5\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n def test_pydantic_nested_alias_takes_effect(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"900\")\n assert _resolve_llm_timeout_seconds() == 900.0\n\n def test_explicit_env_wins_over_pydantic_alias(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"120\")\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"900\")\n assert _resolve_llm_timeout_seconds() == 120.0\n\n def test_alias_invalid_value_attributes_error_to_alias(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"nonsense\")\n with pytest.raises(ValueError, match=\"DECEPTICON_LLM__TIMEOUT\"):\n _resolve_llm_timeout_seconds()\n\n def test_alias_non_positive_attributes_error_to_alias(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"0\")\n with pytest.raises(ValueError, match=\"DECEPTICON_LLM__TIMEOUT.*greater than 0\"):\n _resolve_llm_timeout_seconds()\n"}, "4bbce381e4df4072020ff7aac90d4be0e7a6367e": {"t": 1785755391.3305619, "n": 61120, "h": "1e496f1aa0f30c98", "c": "\"\"\"Unit tests for decepticon.llm.factory.\"\"\"\n\nimport asyncio\nimport logging\n\nimport pytest\n\nfrom decepticon.llm.factory import (\n LLMFactory,\n _is_real_key,\n _llamacpp_local_configured,\n _method_is_configured,\n _oauth_credentials_present,\n _oauth_env_credentials_present,\n _resolve_credentials,\n)\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\n\n\nclass TestIsRealKey:\n \"\"\"Vendor-aware API key validation.\n\n The launcher writes ``your-…-key-here`` placeholders into .env so the\n user can later swap in a real key. The factory needs to reject those\n plus any obvious junk (short strings, ``placeholder``/``not-used``\n markers, vendor-prefix mismatches) so the credentials inventory\n reflects what actually works at request time.\n \"\"\"\n\n def test_rejects_empty_and_placeholder_template(self) -> None:\n assert _is_real_key(\"\") is False\n assert _is_real_key(\" \") is False\n assert _is_real_key(\"your-anthropic-key-here\") is False\n assert _is_real_key(\"YOUR-OPENAI-KEY-HERE\") is False # case-insensitive\n\n def test_rejects_short_strings(self) -> None:\n # Under 24 chars — every vendor-issued key exceeds this.\n assert _is_real_key(\"sk-tooshort\") is False\n\n def test_rejects_placeholder_tokens_in_value(self) -> None:\n long_enough = \"x\" * 30\n for token in (\"placeholder\", \"not-used\", \"dummy\", \"fake\", \"example\"):\n assert _is_real_key(f\"sk-{token}-{long_enough}\") is False, token\n\n def test_accepts_realistic_keys_without_method(self) -> None:\n # Without method context, prefix check is skipped.\n assert _is_real_key(\"sk-ant-api03-realtokenfortestingauthrouting12345\") is True\n assert _is_real_key(\"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\") is True\n\n def test_rejects_wrong_vendor_prefix(self) -> None:\n # An OpenAI-shaped key in the Anthropic slot must be caught.\n openai_key = \"sk-proj-realopenaitokenfortestingauthrouting12345\"\n assert _is_real_key(openai_key, AuthMethod.ANTHROPIC_API) is False\n # …and vice versa.\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.GOOGLE_API) is False\n\n def test_accepts_correct_vendor_prefix(self) -> None:\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.ANTHROPIC_API) is True\n google_key = \"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\"\n assert _is_real_key(google_key, AuthMethod.GOOGLE_API) is True\n\n\nclass TestOAuthCredentialsPresent:\n \"\"\"OAuth detection requires the credential file alongside the boolean.\n\n Without the file check, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place OAuth in\n every fallback chain and generate one 401 per request.\n \"\"\"\n\n def test_returns_false_when_file_missing(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(tmp_path / \"absent.json\"))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_when_file_is_empty(self, monkeypatch, tmp_path) -> None:\n # ``/dev/null``-style mounts read as empty — must fail closed.\n empty = tmp_path / \"empty.json\"\n empty.write_text(\"\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(empty))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_on_invalid_json(self, monkeypatch, tmp_path) -> None:\n bad = tmp_path / \"bad.json\"\n bad.write_text(\"{not-json\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(bad))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_true_when_file_is_well_formed(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"credentials.json\"\n good.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeef\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is True\n\n def test_codex_path_via_env_override(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"auth.json\"\n good.write_text('{\"tokens\": {\"access_token\": \"ABC\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CODEX_AUTH_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.OPENAI_OAUTH) is True\n\n\nclass TestOAuthEnvCredentials:\n \"\"\"B12: env-backed OAuth creds count as configured, not just on-disk files.\n Before the fix, ``_method_is_configured`` required a credential *file* even\n though the LiteLLM handlers also accept env-var tokens (GEMINI_ACCESS_TOKEN,\n GROK_SESSION_TOKEN, COPILOT_REFRESH_TOKEN, …). Env-only setups were silently\n dropped from the fallback chain.\n \"\"\"\n\n def test_env_present_returns_true_for_each_method(self, monkeypatch) -> None:\n cases = {\n AuthMethod.GOOGLE_OAUTH: \"GEMINI_ACCESS_TOKEN\",\n AuthMethod.COPILOT_OAUTH: \"COPILOT_REFRESH_TOKEN\",\n AuthMethod.GROK_OAUTH: \"GROK_SESSION_TOKEN\",\n AuthMethod.PERPLEXITY_OAUTH: \"PERPLEXITY_SESSION_TOKEN\",\n AuthMethod.ANTHROPIC_OAUTH: \"ANTHROPIC_OAUTH_TOKEN\",\n }\n for method, env_var in cases.items():\n monkeypatch.setenv(env_var, \"tok-from-env\")\n assert _oauth_env_credentials_present(method) is True\n monkeypatch.delenv(env_var, raising=False)\n\n def test_env_only_method_is_configured_without_file(self, monkeypatch, tmp_path) -> None:\n # Flag true + env token, but the credential file is absent.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.setenv(\"DECEPTICON_AUTH_GEMINI\", \"true\")\n monkeypatch.setenv(\"GEMINI_ACCESS_TOKEN\", \"ya29.env-token\")\n assert _oauth_credentials_present(AuthMethod.GOOGLE_OAUTH) is False\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is True\n\n def test_method_not_configured_without_file_or_env(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.setenv(\"DECEPTICON_AUTH_GEMINI\", \"true\")\n monkeypatch.delenv(\"GEMINI_ACCESS_TOKEN\", raising=False)\n monkeypatch.delenv(\"GEMINI_SESSION_COOKIES\", raising=False)\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is False\n\n def test_env_creds_still_require_intent_flag(self, monkeypatch, tmp_path) -> None:\n # Env token present but the boolean intent flag is off → not configured.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.delenv(\"DECEPTICON_AUTH_GEMINI\", raising=False)\n monkeypatch.setenv(\"GEMINI_ACCESS_TOKEN\", \"ya29.env-token\")\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is False\n\n def test_codex_has_no_env_token_override(self, monkeypatch) -> None:\n # Codex authenticates only from its file; no env-token override exists.\n monkeypatch.setenv(\"CODEX_ACCESS_TOKEN\", \"should-not-count\")\n assert _oauth_env_credentials_present(AuthMethod.OPENAI_OAUTH) is False\n\n\nclass TestLLMFactory:\n def setup_method(self):\n self.proxy = ProxyConfig(url=\"http://localhost:4000\", api_key=\"test-key\")\n # Build an explicit mapping so the test doesn't depend on env vars.\n creds = Credentials.all_api_methods()\n self.mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n self.factory = LLMFactory(self.proxy, self.mapping)\n\n def test_factory_initializes(self):\n assert self.factory.proxy_url == \"http://localhost:4000\"\n\n def test_get_model_returns_chat_model(self):\n model = self.factory.get_model(\"recon\")\n assert model is not None\n assert model.model_name == \"anthropic/claude-haiku-4-5\"\n\n def test_get_model_caches_instances(self):\n m1 = self.factory.get_model(\"recon\")\n m2 = self.factory.get_model(\"recon\")\n assert m1 is m2\n\n def test_get_model_different_roles_different_models(self):\n recon = self.factory.get_model(\"recon\")\n decepticon = self.factory.get_model(\"decepticon\")\n assert recon is not decepticon\n assert recon.model_name != decepticon.model_name\n\n def test_get_model_unknown_role_raises(self):\n with pytest.raises(KeyError, match=\"No model assignment\"):\n self.factory.get_model(\"nonexistent\")\n\n def test_router_accessible(self):\n assert self.factory.router is not None\n\n def test_get_fallback_models_full_chain(self):\n models = self.factory.get_fallback_models(\"recon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5-nano\",\n \"gemini/gemini-2.5-flash-lite\",\n \"deepseek/deepseek-v4-flash\",\n \"openrouter/anthropic/claude-haiku-4-5\",\n \"nvidia_nim/meta/llama-3.2-3b-instruct\",\n ]\n\n def test_get_fallback_models_high_tier_includes_all_methods(self):\n models = self.factory.get_fallback_models(\"decepticon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5.5\",\n \"gemini/gemini-2.5-pro\",\n \"minimax/MiniMax-M2.5\",\n \"deepseek/deepseek-v4-pro\",\n \"xai/grok-4.3\",\n \"mistral/mistral-large-latest\",\n \"openrouter/anthropic/claude-opus-4-7\",\n \"nvidia_nim/meta/llama-3.3-70b-instruct\",\n ]\n\n def test_get_fallback_models_without_fallback(self):\n # Single-credential mapping → no fallback.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n factory = LLMFactory(self.proxy, mapping)\n assert factory.get_fallback_models(\"recon\") == []\n\n def test_explicit_credentials_param(self):\n # Constructor accepts a Credentials object instead of a full mapping.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n factory = LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n assert factory.get_model(\"decepticon\").model_name == \"openai/gpt-5.5\"\n\n\nclass TestLLMFactoryEmptyChainValidation:\n \"\"\"A credentials inventory whose AuthMethods lack a (method, tier) entry\n for some required tier resolves to an EMPTY model chain at that tier.\n ``LLMModelMapping.from_credentials_and_profile`` silently skips such\n roles, so the failure surfaces much later — either as a confusing\n ``KeyError: No model assignment for role: ...`` at ``get_model`` time\n or, when a caller routes through middleware, as a primary-less\n ``ModelFallbackMiddleware([])``. Validate at construction so the\n operator gets one actionable error naming the empty tier(s) and the\n configured credentials inventory.\"\"\"\n\n def setup_method(self) -> None:\n self.proxy = ProxyConfig(url=\"http://localhost:4000\", api_key=\"test-key\")\n\n def test_empty_tier_raises_at_init_with_inventory_listed(self) -> None:\n # MINIMAX_API has only HIGH+MID entries in METHOD_MODELS — no LOW.\n # ECO routes ``recon`` (and other LOW roles) to Tier.LOW, so the\n # LOW chain resolves to []. The factory must refuse to construct.\n creds = Credentials(methods=[AuthMethod.MINIMAX_API])\n with pytest.raises(ValueError) as exc_info:\n LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n msg = str(exc_info.value)\n # Names the empty tier so the operator knows what to fix.\n assert \"low\" in msg.lower()\n # Echoes the configured credentials so the operator can audit\n # what was actually detected (vs. what they think they set).\n assert AuthMethod.MINIMAX_API.value in msg\n\n def test_test_profile_empty_high_chain_raises(self) -> None:\n # MINIMAX_API covers HIGH+MID but not LOW; TEST profile forces every\n # role to LOW → empty chain across the board.\n creds = Credentials(methods=[AuthMethod.MINIMAX_API])\n with pytest.raises(ValueError, match=\"(?i)low\"):\n LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.TEST)\n\n def test_valid_inventory_still_constructs(self) -> None:\n # OPENAI_API covers all three tiers — no empty chain, no regression.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n factory = LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n assert factory.get_model(\"recon\").model_name == \"openai/gpt-5-nano\"\n assert factory.get_model(\"decepticon\").model_name == \"openai/gpt-5.5\"\n\n def test_explicit_mapping_bypasses_validation(self) -> None:\n # When the caller supplies a mapping directly, they own its shape —\n # the credentials-based validation does not apply (existing tests\n # build empty/partial mappings explicitly).\n factory = LLMFactory(self.proxy, mapping=LLMModelMapping())\n assert factory.proxy_url == \"http://localhost:4000\"\n\n\nclass TestLLMFactoryHealthCheck:\n def test_health_check_returns_false_when_no_proxy(self):\n proxy = ProxyConfig(url=\"http://localhost:19999\")\n factory = LLMFactory(proxy, mapping=LLMModelMapping())\n assert asyncio.run(factory.health_check()) is False\n\n\nclass TestResolveCredentials:\n def test_real_keys_only(self, monkeypatch):\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"your-openai-key-here\") # placeholder\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_API]\n\n def test_oauth_only(self, monkeypatch, tmp_path):\n # OAuth detection requires the credential FILE alongside the\n # boolean — point Claude Code at a temp credentials file so the\n # test runs deterministically regardless of host state.\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_OAUTH]\n\n def test_oauth_flag_without_credential_file_is_dropped(self, monkeypatch, tmp_path):\n \"\"\"Stale ``DECEPTICON_AUTH_CLAUDE_CODE=true`` after ``codex logout``\n (or after the user deleted ``~/.claude/.credentials.json``) must\n not place the OAuth method into the chain — otherwise every\n request 401s before falling back to the next provider.\n \"\"\"\n # Point both the primary and legacy fallback paths at tmp_path so\n # any ``~/.claude/.credentials.json`` or\n # ``~/.config/anthropic/q/tokens.json`` on the dev host doesn't\n # accidentally satisfy the file-presence check.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n missing = tmp_path / \"missing.json\" # never created\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n creds = _resolve_credentials()\n assert AuthMethod.ANTHROPIC_OAUTH not in creds.methods\n\n def test_oauth_plus_api_priority_default(self, monkeypatch, tmp_path):\n # Default priority is anthropic_oauth > anthropic_api > openai_api ...\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n ]\n\n def test_explicit_priority_override(self, monkeypatch):\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"openai_api,anthropic_api\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n monkeypatch.delenv(\"GEMINI_API_KEY\", raising=False)\n monkeypatch.delenv(\"MINIMAX_API_KEY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OPENAI_API, AuthMethod.ANTHROPIC_API]\n\n def test_placeholder_falls_back_to_all_api_methods(self, monkeypatch):\n \"\"\"When every detected method is a placeholder/missing, the resolver\n falls back to the all-API-methods inventory so module-level agent\n constructors stay importable in CI / dev shells without keys.\"\"\"\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"your-anthropic-key-here\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n ]\n\n def test_explicit_priority_no_creds_logs_error(self, monkeypatch, tmp_path, caplog):\n \"\"\"An explicit ``DECEPTICON_AUTH_PRIORITY`` whose every listed\n method fails detection (typical of a broken OAuth credentials\n mount — e.g. ``CLAUDE_CREDENTIALS_VOLUME`` unset and the\n compose file bound ``/dev/null`` into the container) must\n surface the root cause at ERROR level.\n\n Otherwise the silent fallback to ``all_api_methods()`` runs\n through nine unrelated providers and the operator only sees a\n downstream 401-cascade — confusingly masked as a 429 once the\n routed-to provider (e.g. NVIDIA NIM) cools down. Backward\n compat is preserved: the resolver still returns\n ``all_api_methods()`` so module imports remain green; the\n real model call surfaces a separate, actionable error via\n ``_reraise_with_actionable_message``.\n \"\"\"\n # Point the OAuth credential path at a file we never create so\n # detection fails the same way ``/dev/null`` does at runtime.\n missing = tmp_path / \"missing.json\"\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"anthropic_oauth\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n # ``decepticon_core.utils.logging`` sets the parent decepticon logger\n # to ``propagate=False`` so library output doesn't double up in\n # apps that own their root handler. caplog hooks the root logger,\n # so without re-enabling propagation our ERROR record never\n # reaches pytest's capture buffer. Re-enable for the duration of\n # the call and let monkeypatch restore on teardown.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.ERROR):\n creds = _resolve_credentials()\n\n # Backward compat: still returns the all-API-methods fallback.\n assert creds.methods == Credentials.all_api_methods().methods\n\n # ERROR diagnostic must mention the env var name so an operator\n # scanning ``decepticon logs langgraph`` knows where to look.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert any(\"DECEPTICON_AUTH_PRIORITY\" in r.getMessage() for r in error_records), (\n f\"No ERROR log mentioned DECEPTICON_AUTH_PRIORITY. \"\n f\"Got: {[r.getMessage() for r in error_records]}\"\n )\n\n def test_implicit_priority_no_creds_stays_info_level(self, monkeypatch, caplog):\n \"\"\"Implicit priority (no DECEPTICON_AUTH_PRIORITY set) with no\n detectable credentials is the import-friendly CI/test path:\n we keep the existing INFO log and ``all_api_methods()`` return\n so module imports work without keys present. Only **explicit**\n priority with no matches gets escalated to ERROR.\n \"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n\n # Match the explicit-priority test's pattern so caplog sees the\n # records — the decepticon parent logger defaults to\n # ``propagate=False`` per ``decepticon_core.utils.logging``.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.DEBUG):\n creds = _resolve_credentials()\n\n assert creds.methods == Credentials.all_api_methods().methods\n # No ERROR records — this case stays informational.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert not error_records, (\n f\"Unexpected ERROR logs in implicit-priority path: \"\n f\"{[r.getMessage() for r in error_records]}\"\n )\n\n def test_ollama_local_only_returns_ollama_chain(self, monkeypatch):\n \"\"\"Issue #106: a user with only OLLAMA_API_BASE / OLLAMA_MODEL set\n (no API keys, no OAuth) must get a chain of one — Ollama only.\n Falling back to all-API-methods would produce 401 errors on every\n provider the user doesn't have.\"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL]\n\n def test_explicit_priority_with_ollama_local(self, monkeypatch):\n \"\"\"User opts into Ollama via explicit priority — the resolver\n recognizes it as configured when OLLAMA_API_BASE is set.\"\"\"\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"ollama_local,anthropic_api\")\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\n# ── Temperature handling (issue #107) ───────────────────────────────────\n\n\nclass TestTemperatureDrop:\n \"\"\"Claude Opus 4.7 rejects ``temperature`` regardless of route. The\n factory must drop it on every Opus 4 surface (anthropic/, auth/,\n openrouter/anthropic/) and keep it for everyone else.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _model_drops_temperature\n\n self._drops = _model_drops_temperature\n\n def test_anthropic_opus_drops_temperature(self):\n assert self._drops(\"anthropic/claude-opus-4-7\") is True\n\n def test_oauth_opus_drops_temperature(self):\n assert self._drops(\"auth/claude-opus-4-7\") is True\n\n def test_openrouter_opus_drops_temperature(self):\n assert self._drops(\"openrouter/anthropic/claude-opus-4-7\") is True\n\n def test_sonnet_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-sonnet-4-6\") is False\n\n def test_haiku_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-haiku-4-5\") is False\n\n def test_openai_keeps_temperature(self):\n assert self._drops(\"openai/gpt-5.5\") is False\n\n def test_ollama_keeps_temperature(self):\n assert self._drops(\"ollama_chat/qwen3-coder:30b\") is False\n\n\n# ── Actionable error translation (issue #107 + community feedback) ──────\n\n\nclass TestActionableErrorTranslation:\n \"\"\"The OSS user complaint: every upstream failure surfaces as 'An\n internal error occurred', stripping the message that would tell them\n what to fix. Each branch below verifies one class of error gets\n rewritten with a remediation hint and the model id that hit the\n failure.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _reraise_with_actionable_message\n\n self._translate = _reraise_with_actionable_message\n\n def test_no_fallback_model_group_branch(self):\n exc = Exception(\n \"litellm.BadRequestError: ... No fallback model group found \"\n \"for original model_group=anthropic/claude-opus-4-7.\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"no provider fallback\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_400_bad_request_branch(self):\n # openai.BadRequestError carries 'Error code: 400' in repr.\n exc = Exception(\"Error code: 400 - {'error': {'message': 'temperature is deprecated'}}\")\n type(exc).__name__ # noqa: B018 — sanity, ensures Exception default name\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n assert \"rejected the request (400)\" in str(info.value)\n\n def test_401_authentication_branch(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\"Error code: 401 - invalid_api_key\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"credentials (401)\" in msg\n assert \"decepticon onboard --reset\" in msg\n\n def test_429_ratelimit_branch(self):\n exc = type(\"RateLimitError\", (Exception,), {})(\"Error code: 429\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"rate limit (429)\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_404_notfound_with_ollama_hint(self):\n exc = type(\"NotFoundError\", (Exception,), {})(\"Error code: 404 - model not found\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"ollama_chat/nonexistent\")\n msg = str(info.value)\n assert \"404\" in msg\n assert \"OLLAMA_MODEL\" in msg\n\n def test_unmatched_error_passes_through(self):\n # Anything we don't recognize must NOT raise — the caller's\n # ``raise`` follows and re-raises the original exception with\n # full traceback.\n exc = ValueError(\"something completely unrelated\")\n # Should not raise from the helper.\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n\n def test_401_redacts_bearer_token_keeps_guidance(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\n \"Error code: 401 - {'error': 'invalid_api_key'} \"\n \"Authorization: Bearer sk-ant-SECRET123FAKEPLACEHOLDERTOKEN\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"sk-ant-SECRET123FAKEPLACEHOLDERTOKEN\" not in msg\n assert \"SECRET123FAKEPLACEHOLDERTOKEN\" not in msg\n assert \"[REDACTED]\" in msg\n assert \"credentials (401)\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n\n def test_400_redacts_api_key_kwarg_keeps_guidance(self):\n exc = Exception(\n \"Error code: 400 - bad request from provider with api_key=sk-SECRETfakeplaceholdervalue99\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"sk-SECRETfakeplaceholdervalue99\" not in msg\n assert \"SECRETfakeplaceholdervalue99\" not in msg\n assert \"[REDACTED]\" in msg\n assert \"rejected the request (400)\" in msg\n\n def test_redact_secrets_preserves_nonsecret_text(self):\n from decepticon.llm.factory import _redact_secrets\n\n text = \"No fallback model group found for model_group=anthropic/claude-opus-4-7.\"\n assert _redact_secrets(text) == text\n\n\n# ── DeepSeek V4 Pro reasoning_content passthrough ────────────────────────\n\n\nclass TestDeepSeekReasoningContent:\n \"\"\"Verify reasoning_content survives both the non-streaming and streaming\n paths so it can be sent back on subsequent API turns.\"\"\"\n\n def test_create_chat_result_extracts_reasoning_content(self):\n \"\"\"Non-streaming: _create_chat_result captures reasoning_content\n from the response dict's choice message.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Simulate the OpenAI response dict with reasoning_content\n response_dict = {\n \"choices\": [\n {\n \"message\": {\n \"role\": \"assistant\",\n \"content\": \"Four\",\n \"reasoning_content\": \"I think therefore I am\",\n },\n \"finish_reason\": \"stop\",\n }\n ],\n \"usage\": {},\n }\n\n # Mock the parent's _create_chat_result to return a ChatResult without reasoning_content\n msg = AIMessage(content=\"Four\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert (\n result.generations[0].message.additional_kwargs[\"reasoning_content\"]\n == \"I think therefore I am\"\n )\n\n def test_create_chat_result_skips_when_absent(self):\n \"\"\"Non-streaming: no crash when reasoning_content is missing.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n response_dict = {\n \"choices\": [\n {\"message\": {\"role\": \"assistant\", \"content\": \"hi\"}, \"finish_reason\": \"stop\"}\n ],\n \"usage\": {},\n }\n\n msg = AIMessage(content=\"hi\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert \"reasoning_content\" not in result.generations[0].message.additional_kwargs\n\n def test_convert_chunk_injects_reasoning_content(self):\n \"\"\"Streaming: _convert_chunk_to_generation_chunk captures\n reasoning_content from the raw delta and injects it into\n AIMessageChunk.additional_kwargs.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Build a fake raw SSE chunk dict with reasoning_content in the delta\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Let me think...\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n # The parent's _convert_chunk_to_generation_chunk builds the\n # ChatGenerationChunk but ignores reasoning_content. We mock it\n # to return a chunk without reasoning_content, then verify our\n # override injects it.\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n # Bind the real method to our mock instance\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert result.message.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n\n def test_convert_chunk_no_reasoning_leaves_kwargs_clean(self):\n \"\"\"Streaming: when delta has no reasoning_content, additional_kwargs\n is not polluted.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\"role\": \"assistant\", \"content\": \"hi\"},\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_reasoning_content_accumulates_across_chunks(self):\n \"\"\"Streaming: reasoning_content from multiple chunks concatenates\n via AIMessageChunk.__add__ (merge_dicts).\"\"\"\n from langchain_core.messages import AIMessageChunk\n\n c1 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"Let me \"})\n c2 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"think...\"})\n c3 = AIMessageChunk(content=\"Hello!\", additional_kwargs={})\n\n merged = c1 + c2 + c3\n assert merged.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n assert merged.content == \"Hello!\"\n\n def test_get_request_payload_injects_reasoning_content(self):\n \"\"\"Outbound: _get_request_payload injects reasoning_content from\n AIMessage.additional_kwargs into serialized assistant message dicts.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"hi\"),\n AIMessage(\n content=\"hello\",\n additional_kwargs={\"reasoning_content\": \"thinking...\"},\n ),\n ]\n\n # Mock super()._get_request_payload to return a payload without reasoning_content\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"hi\"},\n {\"role\": \"assistant\", \"content\": \"hello\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thinking...\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert payload[\"extra_body\"][\"thinking\"] == {\"type\": \"enabled\"}\n assert payload[\"reasoning_effort\"] == \"high\"\n\n def test_convert_chunk_empty_reasoning_content_ignored(self):\n \"\"\"Streaming: empty string reasoning_content in delta is ignored.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"hi\",\n \"reasoning_content\": \"\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n # Empty string is falsy — should not pollute additional_kwargs\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_none_delta_no_crash(self):\n \"\"\"Streaming: None delta in choices does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": None,\n \"finish_reason\": \"stop\",\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_empty_choices_no_crash(self):\n \"\"\"Streaming: empty choices array does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"choices\": []}\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_parent_returns_none(self):\n \"\"\"Streaming: when parent returns None, we return None too.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"type\": \"content.delta\"} # parent returns None for these\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=None,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is None\n\n def test_convert_chunk_beta_stream_format(self):\n \"\"\"Streaming: handles beta.chat.completions.stream nested chunk format.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Some LangChain versions nest under \"chunk\" key\n raw_chunk = {\n \"chunk\": {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Thinking via beta format...\",\n },\n \"finish_reason\": None,\n }\n ],\n },\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert (\n result.message.additional_kwargs[\"reasoning_content\"] == \"Thinking via beta format...\"\n )\n\n def test_get_request_payload_multiple_assistant_messages(self):\n \"\"\"Outbound: each assistant message gets its own reasoning_content.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"q1\"),\n AIMessage(content=\"a1\", additional_kwargs={\"reasoning_content\": \"thought1\"}),\n HumanMessage(content=\"q2\"),\n AIMessage(content=\"a2\", additional_kwargs={\"reasoning_content\": \"thought2\"}),\n ]\n\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"q1\"},\n {\"role\": \"assistant\", \"content\": \"a1\"},\n {\"role\": \"user\", \"content\": \"q2\"},\n {\"role\": \"assistant\", \"content\": \"a2\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thought1\"\n assert payload[\"messages\"][3][\"reasoning_content\"] == \"thought2\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert \"reasoning_content\" not in payload[\"messages\"][2]\n\n def test_model_detection(self):\n \"\"\"Factory routes DeepSeek V4 (pro + flash) and legacy reasoner.\n\n Per DeepSeek's API docs ``deepseek-reasoner`` is the deprecated\n alias for ``deepseek-v4-flash`` thinking mode, so v4-flash also\n returns ``reasoning_content`` and the API rejects subsequent\n tool turns when the field is omitted. Closes #201, #220.\n \"\"\"\n from decepticon.llm.factory import _model_is_deepseek_thinking\n\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-pro\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-flash\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-reasoner\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-chat\") is False\n assert _model_is_deepseek_thinking(\"openai/gpt-5.5\") is False\n\n\n# ── LLAMACPP_LOCAL credential detection (issue #151) ────────────────────\n\n\n_LLAMACPP_RELATED_ENV = (\n \"LLAMACPP_API_BASE\",\n \"LLAMACPP_MODEL\",\n \"LLAMACPP_API_KEY\",\n)\n\n\ndef _scrub_other_credentials(monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"Delete any env that would be detected as another credential.\n\n The credential-detection tests below need to assert \"only LLAMACPP\n is detected\"; without this scrub, a developer's exported\n ``ANTHROPIC_API_KEY`` would creep in and the assertion would fail\n locally only.\n \"\"\"\n for var in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"GROQ_API_KEY\",\n \"TOGETHER_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"COHERE_API_KEY\",\n \"MOONSHOT_API_KEY\",\n \"ZAI_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"GITHUB_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n ):\n monkeypatch.delenv(var, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n\nclass TestLlamacppLocalConfigured:\n \"\"\"``_llamacpp_local_configured`` is the gate for adding\n ``LLAMACPP_LOCAL`` to the credentials chain. Either env var being\n set is enough — neither requires the other, mirroring the\n LM Studio / Ollama detection contract.\n \"\"\"\n\n def test_returns_false_when_neither_env_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n assert _llamacpp_local_configured() is False\n\n def test_returns_true_when_only_base_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n assert _llamacpp_local_configured() is True\n\n def test_returns_true_when_only_model_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n assert _llamacpp_local_configured() is True\n\n def test_whitespace_does_not_count_as_set(self, monkeypatch):\n \"\"\"Stray ``LLAMACPP_API_BASE=`` lines in .env must not silently\n opt the user in — same fail-soft as the Ollama detector.\"\"\"\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \" \")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"\")\n assert _llamacpp_local_configured() is False\n\n\nclass TestResolveCredentialsForLlamacpp:\n \"\"\"End-to-end check that ``_resolve_credentials`` picks up the\n user's llama.cpp config — both the explicit-priority path and the\n \"only LLAMACPP_* detected\" auto-fallback at the bottom of\n ``_resolve_credentials``.\n \"\"\"\n\n def test_only_llamacpp_detected_returns_llamacpp_only_chain(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL]\n\n def test_priority_list_with_llamacpp_picks_it_up(self, monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"User wrote an explicit DECEPTICON_AUTH_PRIORITY listing\n llamacpp_local — the resolver must respect that ordering.\"\"\"\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen-coder\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"llamacpp_local,anthropic_api\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\nclass TestLLMTimeout:\n \"\"\"Whole-coroutine LLM request timeout (DECEPTICON_LLM_TIMEOUT_SECONDS).\"\"\"\n\n @pytest.fixture(autouse=True)\n def _isolate_timeout_env(self, monkeypatch: pytest.MonkeyPatch) -> None:\n monkeypatch.delenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_LLM__TIMEOUT\", raising=False)\n\n def test_call_with_timeout_raises_typed_exception(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> str:\n await asyncio.sleep(10)\n return \"done\"\n\n with pytest.raises(LLMTimeoutError, match=\"timed out after 0.01 seconds\"):\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n\n def test_call_with_timeout_preserves_original_timeout_as_cause(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> None:\n await asyncio.sleep(10)\n\n try:\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n except LLMTimeoutError as exc:\n assert isinstance(exc.__cause__, asyncio.TimeoutError)\n else:\n raise AssertionError(\"expected LLMTimeoutError\")\n\n def test_call_with_timeout_returns_successful_result(self) -> None:\n from decepticon.llm.factory import call_with_timeout\n\n async def fast_call() -> str:\n await asyncio.sleep(0)\n return \"ok\"\n\n assert asyncio.run(call_with_timeout(fast_call(), 1)) == \"ok\"\n\n def test_call_with_timeout_propagates_non_timeout_exceptions(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def failing_call() -> None:\n raise RuntimeError(\"upstream 500\")\n\n with pytest.raises(RuntimeError, match=\"upstream 500\"):\n asyncio.run(call_with_timeout(failing_call(), 1))\n # And not as a timeout error\n try:\n asyncio.run(call_with_timeout(failing_call(), 1))\n except LLMTimeoutError:\n raise AssertionError(\"non-timeout exception was retyped\")\n except RuntimeError:\n pass\n\n def test_env_override_takes_effect(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"3\")\n assert _resolve_llm_timeout_seconds() == 3.0\n\n def test_default_timeout_is_600_when_env_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.delenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", raising=False)\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_blank_env_falls_back_to_default(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \" \")\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_invalid_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"not-a-number\")\n with pytest.raises(ValueError):\n _resolve_llm_timeout_seconds()\n\n def test_non_positive_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"0\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"-5\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n def test_pydantic_nested_alias_takes_effect(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"900\")\n assert _resolve_llm_timeout_seconds() == 900.0\n\n def test_explicit_env_wins_over_pydantic_alias(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"120\")\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"900\")\n assert _resolve_llm_timeout_seconds() == 120.0\n\n def test_alias_invalid_value_attributes_error_to_alias(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"nonsense\")\n with pytest.raises(ValueError, match=\"DECEPTICON_LLM__TIMEOUT\"):\n _resolve_llm_timeout_seconds()\n\n def test_alias_non_positive_attributes_error_to_alias(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM__TIMEOUT\", \"0\")\n with pytest.raises(ValueError, match=\"DECEPTICON_LLM__TIMEOUT.*greater than 0\"):\n _resolve_llm_timeout_seconds()\n"}, "4a31bb57feca58e32d9fac1e8cccf44c1ab2a4e9": {"t": 1785755394.2315784, "n": 59458, "h": "d4c4d46d0fd9c4c8", "c": "\"\"\"Unit tests for decepticon.llm.factory.\"\"\"\n\nimport asyncio\nimport logging\n\nimport pytest\n\nfrom decepticon.llm.factory import (\n LLMFactory,\n _is_real_key,\n _llamacpp_local_configured,\n _method_is_configured,\n _oauth_credentials_present,\n _oauth_env_credentials_present,\n _resolve_credentials,\n)\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\n\n\nclass TestIsRealKey:\n \"\"\"Vendor-aware API key validation.\n\n The launcher writes ``your-…-key-here`` placeholders into .env so the\n user can later swap in a real key. The factory needs to reject those\n plus any obvious junk (short strings, ``placeholder``/``not-used``\n markers, vendor-prefix mismatches) so the credentials inventory\n reflects what actually works at request time.\n \"\"\"\n\n def test_rejects_empty_and_placeholder_template(self) -> None:\n assert _is_real_key(\"\") is False\n assert _is_real_key(\" \") is False\n assert _is_real_key(\"your-anthropic-key-here\") is False\n assert _is_real_key(\"YOUR-OPENAI-KEY-HERE\") is False # case-insensitive\n\n def test_rejects_short_strings(self) -> None:\n # Under 24 chars — every vendor-issued key exceeds this.\n assert _is_real_key(\"sk-tooshort\") is False\n\n def test_rejects_placeholder_tokens_in_value(self) -> None:\n long_enough = \"x\" * 30\n for token in (\"placeholder\", \"not-used\", \"dummy\", \"fake\", \"example\"):\n assert _is_real_key(f\"sk-{token}-{long_enough}\") is False, token\n\n def test_accepts_realistic_keys_without_method(self) -> None:\n # Without method context, prefix check is skipped.\n assert _is_real_key(\"sk-ant-api03-realtokenfortestingauthrouting12345\") is True\n assert _is_real_key(\"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\") is True\n\n def test_rejects_wrong_vendor_prefix(self) -> None:\n # An OpenAI-shaped key in the Anthropic slot must be caught.\n openai_key = \"sk-proj-realopenaitokenfortestingauthrouting12345\"\n assert _is_real_key(openai_key, AuthMethod.ANTHROPIC_API) is False\n # …and vice versa.\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.GOOGLE_API) is False\n\n def test_accepts_correct_vendor_prefix(self) -> None:\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.ANTHROPIC_API) is True\n google_key = \"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\"\n assert _is_real_key(google_key, AuthMethod.GOOGLE_API) is True\n\n\nclass TestOAuthCredentialsPresent:\n \"\"\"OAuth detection requires the credential file alongside the boolean.\n\n Without the file check, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place OAuth in\n every fallback chain and generate one 401 per request.\n \"\"\"\n\n def test_returns_false_when_file_missing(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(tmp_path / \"absent.json\"))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_when_file_is_empty(self, monkeypatch, tmp_path) -> None:\n # ``/dev/null``-style mounts read as empty — must fail closed.\n empty = tmp_path / \"empty.json\"\n empty.write_text(\"\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(empty))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_on_invalid_json(self, monkeypatch, tmp_path) -> None:\n bad = tmp_path / \"bad.json\"\n bad.write_text(\"{not-json\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(bad))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_true_when_file_is_well_formed(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"credentials.json\"\n good.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeef\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is True\n\n def test_codex_path_via_env_override(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"auth.json\"\n good.write_text('{\"tokens\": {\"access_token\": \"ABC\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CODEX_AUTH_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.OPENAI_OAUTH) is True\n\n\nclass TestOAuthEnvCredentials:\n \"\"\"B12: env-backed OAuth creds count as configured, not just on-disk files.\n Before the fix, ``_method_is_configured`` required a credential *file* even\n though the LiteLLM handlers also accept env-var tokens (GEMINI_ACCESS_TOKEN,\n GROK_SESSION_TOKEN, COPILOT_REFRESH_TOKEN, …). Env-only setups were silently\n dropped from the fallback chain.\n \"\"\"\n\n def test_env_present_returns_true_for_each_method(self, monkeypatch) -> None:\n cases = {\n AuthMethod.GOOGLE_OAUTH: \"GEMINI_ACCESS_TOKEN\",\n AuthMethod.COPILOT_OAUTH: \"COPILOT_REFRESH_TOKEN\",\n AuthMethod.GROK_OAUTH: \"GROK_SESSION_TOKEN\",\n AuthMethod.PERPLEXITY_OAUTH: \"PERPLEXITY_SESSION_TOKEN\",\n AuthMethod.ANTHROPIC_OAUTH: \"ANTHROPIC_OAUTH_TOKEN\",\n }\n for method, env_var in cases.items():\n monkeypatch.setenv(env_var, \"tok-from-env\")\n assert _oauth_env_credentials_present(method) is True\n monkeypatch.delenv(env_var, raising=False)\n\n def test_env_only_method_is_configured_without_file(self, monkeypatch, tmp_path) -> None:\n # Flag true + env token, but the credential file is absent.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.setenv(\"DECEPTICON_AUTH_GEMINI\", \"true\")\n monkeypatch.setenv(\"GEMINI_ACCESS_TOKEN\", \"ya29.env-token\")\n assert _oauth_credentials_present(AuthMethod.GOOGLE_OAUTH) is False\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is True\n\n def test_method_not_configured_without_file_or_env(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.setenv(\"DECEPTICON_AUTH_GEMINI\", \"true\")\n monkeypatch.delenv(\"GEMINI_ACCESS_TOKEN\", raising=False)\n monkeypatch.delenv(\"GEMINI_SESSION_COOKIES\", raising=False)\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is False\n\n def test_env_creds_still_require_intent_flag(self, monkeypatch, tmp_path) -> None:\n # Env token present but the boolean intent flag is off → not configured.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.delenv(\"DECEPTICON_AUTH_GEMINI\", raising=False)\n monkeypatch.setenv(\"GEMINI_ACCESS_TOKEN\", \"ya29.env-token\")\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is False\n\n def test_codex_has_no_env_token_override(self, monkeypatch) -> None:\n # Codex authenticates only from its file; no env-token override exists.\n monkeypatch.setenv(\"CODEX_ACCESS_TOKEN\", \"should-not-count\")\n assert _oauth_env_credentials_present(AuthMethod.OPENAI_OAUTH) is False\n\n\nclass TestLLMFactory:\n def setup_method(self):\n self.proxy = ProxyConfig(url=\"http://localhost:4000\", api_key=\"test-key\")\n # Build an explicit mapping so the test doesn't depend on env vars.\n creds = Credentials.all_api_methods()\n self.mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n self.factory = LLMFactory(self.proxy, self.mapping)\n\n def test_factory_initializes(self):\n assert self.factory.proxy_url == \"http://localhost:4000\"\n\n def test_get_model_returns_chat_model(self):\n model = self.factory.get_model(\"recon\")\n assert model is not None\n assert model.model_name == \"anthropic/claude-haiku-4-5\"\n\n def test_get_model_caches_instances(self):\n m1 = self.factory.get_model(\"recon\")\n m2 = self.factory.get_model(\"recon\")\n assert m1 is m2\n\n def test_get_model_different_roles_different_models(self):\n recon = self.factory.get_model(\"recon\")\n decepticon = self.factory.get_model(\"decepticon\")\n assert recon is not decepticon\n assert recon.model_name != decepticon.model_name\n\n def test_get_model_unknown_role_raises(self):\n with pytest.raises(KeyError, match=\"No model assignment\"):\n self.factory.get_model(\"nonexistent\")\n\n def test_router_accessible(self):\n assert self.factory.router is not None\n\n def test_get_fallback_models_full_chain(self):\n models = self.factory.get_fallback_models(\"recon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5-nano\",\n \"gemini/gemini-2.5-flash-lite\",\n \"deepseek/deepseek-v4-flash\",\n \"openrouter/anthropic/claude-haiku-4-5\",\n \"nvidia_nim/meta/llama-3.2-3b-instruct\",\n ]\n\n def test_get_fallback_models_high_tier_includes_all_methods(self):\n models = self.factory.get_fallback_models(\"decepticon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5.5\",\n \"gemini/gemini-2.5-pro\",\n \"minimax/MiniMax-M2.5\",\n \"deepseek/deepseek-v4-pro\",\n \"xai/grok-4.3\",\n \"mistral/mistral-large-latest\",\n \"openrouter/anthropic/claude-opus-4-7\",\n \"nvidia_nim/meta/llama-3.3-70b-instruct\",\n ]\n\n def test_get_fallback_models_without_fallback(self):\n # Single-credential mapping → no fallback.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n factory = LLMFactory(self.proxy, mapping)\n assert factory.get_fallback_models(\"recon\") == []\n\n def test_explicit_credentials_param(self):\n # Constructor accepts a Credentials object instead of a full mapping.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n factory = LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n assert factory.get_model(\"decepticon\").model_name == \"openai/gpt-5.5\"\n\n\nclass TestLLMFactoryEmptyChainValidation:\n \"\"\"A credentials inventory whose AuthMethods lack a (method, tier) entry\n for some required tier resolves to an EMPTY model chain at that tier.\n ``LLMModelMapping.from_credentials_and_profile`` silently skips such\n roles, so the failure surfaces much later — either as a confusing\n ``KeyError: No model assignment for role: ...`` at ``get_model`` time\n or, when a caller routes through middleware, as a primary-less\n ``ModelFallbackMiddleware([])``. Validate at construction so the\n operator gets one actionable error naming the empty tier(s) and the\n configured credentials inventory.\"\"\"\n\n def setup_method(self) -> None:\n self.proxy = ProxyConfig(url=\"http://localhost:4000\", api_key=\"test-key\")\n\n def test_empty_tier_raises_at_init_with_inventory_listed(self) -> None:\n # MINIMAX_API has only HIGH+MID entries in METHOD_MODELS — no LOW.\n # ECO routes ``recon`` (and other LOW roles) to Tier.LOW, so the\n # LOW chain resolves to []. The factory must refuse to construct.\n creds = Credentials(methods=[AuthMethod.MINIMAX_API])\n with pytest.raises(ValueError) as exc_info:\n LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n msg = str(exc_info.value)\n # Names the empty tier so the operator knows what to fix.\n assert \"low\" in msg.lower()\n # Echoes the configured credentials so the operator can audit\n # what was actually detected (vs. what they think they set).\n assert AuthMethod.MINIMAX_API.value in msg\n\n def test_test_profile_empty_high_chain_raises(self) -> None:\n # MINIMAX_API covers HIGH+MID but not LOW; TEST profile forces every\n # role to LOW → empty chain across the board.\n creds = Credentials(methods=[AuthMethod.MINIMAX_API])\n with pytest.raises(ValueError, match=\"(?i)low\"):\n LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.TEST)\n\n def test_valid_inventory_still_constructs(self) -> None:\n # OPENAI_API covers all three tiers — no empty chain, no regression.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n factory = LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n assert factory.get_model(\"recon\").model_name == \"openai/gpt-5-nano\"\n assert factory.get_model(\"decepticon\").model_name == \"openai/gpt-5.5\"\n\n def test_explicit_mapping_bypasses_validation(self) -> None:\n # When the caller supplies a mapping directly, they own its shape —\n # the credentials-based validation does not apply (existing tests\n # build empty/partial mappings explicitly).\n factory = LLMFactory(self.proxy, mapping=LLMModelMapping())\n assert factory.proxy_url == \"http://localhost:4000\"\n\n\nclass TestLLMFactoryHealthCheck:\n def test_health_check_returns_false_when_no_proxy(self):\n proxy = ProxyConfig(url=\"http://localhost:19999\")\n factory = LLMFactory(proxy, mapping=LLMModelMapping())\n assert asyncio.run(factory.health_check()) is False\n\n\nclass TestResolveCredentials:\n def test_real_keys_only(self, monkeypatch):\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"your-openai-key-here\") # placeholder\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_API]\n\n def test_oauth_only(self, monkeypatch, tmp_path):\n # OAuth detection requires the credential FILE alongside the\n # boolean — point Claude Code at a temp credentials file so the\n # test runs deterministically regardless of host state.\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_OAUTH]\n\n def test_oauth_flag_without_credential_file_is_dropped(self, monkeypatch, tmp_path):\n \"\"\"Stale ``DECEPTICON_AUTH_CLAUDE_CODE=true`` after ``codex logout``\n (or after the user deleted ``~/.claude/.credentials.json``) must\n not place the OAuth method into the chain — otherwise every\n request 401s before falling back to the next provider.\n \"\"\"\n # Point both the primary and legacy fallback paths at tmp_path so\n # any ``~/.claude/.credentials.json`` or\n # ``~/.config/anthropic/q/tokens.json`` on the dev host doesn't\n # accidentally satisfy the file-presence check.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n missing = tmp_path / \"missing.json\" # never created\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n creds = _resolve_credentials()\n assert AuthMethod.ANTHROPIC_OAUTH not in creds.methods\n\n def test_oauth_plus_api_priority_default(self, monkeypatch, tmp_path):\n # Default priority is anthropic_oauth > anthropic_api > openai_api ...\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n ]\n\n def test_explicit_priority_override(self, monkeypatch):\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"openai_api,anthropic_api\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n monkeypatch.delenv(\"GEMINI_API_KEY\", raising=False)\n monkeypatch.delenv(\"MINIMAX_API_KEY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OPENAI_API, AuthMethod.ANTHROPIC_API]\n\n def test_placeholder_falls_back_to_all_api_methods(self, monkeypatch):\n \"\"\"When every detected method is a placeholder/missing, the resolver\n falls back to the all-API-methods inventory so module-level agent\n constructors stay importable in CI / dev shells without keys.\"\"\"\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"your-anthropic-key-here\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n ]\n\n def test_explicit_priority_no_creds_logs_error(self, monkeypatch, tmp_path, caplog):\n \"\"\"An explicit ``DECEPTICON_AUTH_PRIORITY`` whose every listed\n method fails detection (typical of a broken OAuth credentials\n mount — e.g. ``CLAUDE_CREDENTIALS_VOLUME`` unset and the\n compose file bound ``/dev/null`` into the container) must\n surface the root cause at ERROR level.\n\n Otherwise the silent fallback to ``all_api_methods()`` runs\n through nine unrelated providers and the operator only sees a\n downstream 401-cascade — confusingly masked as a 429 once the\n routed-to provider (e.g. NVIDIA NIM) cools down. Backward\n compat is preserved: the resolver still returns\n ``all_api_methods()`` so module imports remain green; the\n real model call surfaces a separate, actionable error via\n ``_reraise_with_actionable_message``.\n \"\"\"\n # Point the OAuth credential path at a file we never create so\n # detection fails the same way ``/dev/null`` does at runtime.\n missing = tmp_path / \"missing.json\"\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"anthropic_oauth\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n # ``decepticon_core.utils.logging`` sets the parent decepticon logger\n # to ``propagate=False`` so library output doesn't double up in\n # apps that own their root handler. caplog hooks the root logger,\n # so without re-enabling propagation our ERROR record never\n # reaches pytest's capture buffer. Re-enable for the duration of\n # the call and let monkeypatch restore on teardown.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.ERROR):\n creds = _resolve_credentials()\n\n # Backward compat: still returns the all-API-methods fallback.\n assert creds.methods == Credentials.all_api_methods().methods\n\n # ERROR diagnostic must mention the env var name so an operator\n # scanning ``decepticon logs langgraph`` knows where to look.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert any(\"DECEPTICON_AUTH_PRIORITY\" in r.getMessage() for r in error_records), (\n f\"No ERROR log mentioned DECEPTICON_AUTH_PRIORITY. \"\n f\"Got: {[r.getMessage() for r in error_records]}\"\n )\n\n def test_implicit_priority_no_creds_stays_info_level(self, monkeypatch, caplog):\n \"\"\"Implicit priority (no DECEPTICON_AUTH_PRIORITY set) with no\n detectable credentials is the import-friendly CI/test path:\n we keep the existing INFO log and ``all_api_methods()`` return\n so module imports work without keys present. Only **explicit**\n priority with no matches gets escalated to ERROR.\n \"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n\n # Match the explicit-priority test's pattern so caplog sees the\n # records — the decepticon parent logger defaults to\n # ``propagate=False`` per ``decepticon_core.utils.logging``.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.DEBUG):\n creds = _resolve_credentials()\n\n assert creds.methods == Credentials.all_api_methods().methods\n # No ERROR records — this case stays informational.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert not error_records, (\n f\"Unexpected ERROR logs in implicit-priority path: \"\n f\"{[r.getMessage() for r in error_records]}\"\n )\n\n def test_ollama_local_only_returns_ollama_chain(self, monkeypatch):\n \"\"\"Issue #106: a user with only OLLAMA_API_BASE / OLLAMA_MODEL set\n (no API keys, no OAuth) must get a chain of one — Ollama only.\n Falling back to all-API-methods would produce 401 errors on every\n provider the user doesn't have.\"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL]\n\n def test_explicit_priority_with_ollama_local(self, monkeypatch):\n \"\"\"User opts into Ollama via explicit priority — the resolver\n recognizes it as configured when OLLAMA_API_BASE is set.\"\"\"\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"ollama_local,anthropic_api\")\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\n# ── Temperature handling (issue #107) ───────────────────────────────────\n\n\nclass TestTemperatureDrop:\n \"\"\"Claude Opus 4.7 rejects ``temperature`` regardless of route. The\n factory must drop it on every Opus 4 surface (anthropic/, auth/,\n openrouter/anthropic/) and keep it for everyone else.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _model_drops_temperature\n\n self._drops = _model_drops_temperature\n\n def test_anthropic_opus_drops_temperature(self):\n assert self._drops(\"anthropic/claude-opus-4-7\") is True\n\n def test_oauth_opus_drops_temperature(self):\n assert self._drops(\"auth/claude-opus-4-7\") is True\n\n def test_openrouter_opus_drops_temperature(self):\n assert self._drops(\"openrouter/anthropic/claude-opus-4-7\") is True\n\n def test_sonnet_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-sonnet-4-6\") is False\n\n def test_haiku_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-haiku-4-5\") is False\n\n def test_openai_keeps_temperature(self):\n assert self._drops(\"openai/gpt-5.5\") is False\n\n def test_ollama_keeps_temperature(self):\n assert self._drops(\"ollama_chat/qwen3-coder:30b\") is False\n\n\n# ── Actionable error translation (issue #107 + community feedback) ──────\n\n\nclass TestActionableErrorTranslation:\n \"\"\"The OSS user complaint: every upstream failure surfaces as 'An\n internal error occurred', stripping the message that would tell them\n what to fix. Each branch below verifies one class of error gets\n rewritten with a remediation hint and the model id that hit the\n failure.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _reraise_with_actionable_message\n\n self._translate = _reraise_with_actionable_message\n\n def test_no_fallback_model_group_branch(self):\n exc = Exception(\n \"litellm.BadRequestError: ... No fallback model group found \"\n \"for original model_group=anthropic/claude-opus-4-7.\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"no provider fallback\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_400_bad_request_branch(self):\n # openai.BadRequestError carries 'Error code: 400' in repr.\n exc = Exception(\"Error code: 400 - {'error': {'message': 'temperature is deprecated'}}\")\n type(exc).__name__ # noqa: B018 — sanity, ensures Exception default name\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n assert \"rejected the request (400)\" in str(info.value)\n\n def test_401_authentication_branch(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\"Error code: 401 - invalid_api_key\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"credentials (401)\" in msg\n assert \"decepticon onboard --reset\" in msg\n\n def test_429_ratelimit_branch(self):\n exc = type(\"RateLimitError\", (Exception,), {})(\"Error code: 429\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"rate limit (429)\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_404_notfound_with_ollama_hint(self):\n exc = type(\"NotFoundError\", (Exception,), {})(\"Error code: 404 - model not found\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"ollama_chat/nonexistent\")\n msg = str(info.value)\n assert \"404\" in msg\n assert \"OLLAMA_MODEL\" in msg\n\n def test_unmatched_error_passes_through(self):\n # Anything we don't recognize must NOT raise — the caller's\n # ``raise`` follows and re-raises the original exception with\n # full traceback.\n exc = ValueError(\"something completely unrelated\")\n # Should not raise from the helper.\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n\n def test_401_redacts_bearer_token_keeps_guidance(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\n \"Error code: 401 - {'error': 'invalid_api_key'} \"\n \"Authorization: Bearer sk-ant-SECRET123FAKEPLACEHOLDERTOKEN\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"sk-ant-SECRET123FAKEPLACEHOLDERTOKEN\" not in msg\n assert \"SECRET123FAKEPLACEHOLDERTOKEN\" not in msg\n assert \"[REDACTED]\" in msg\n assert \"credentials (401)\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n\n def test_400_redacts_api_key_kwarg_keeps_guidance(self):\n exc = Exception(\n \"Error code: 400 - bad request from provider with api_key=sk-SECRETfakeplaceholdervalue99\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"sk-SECRETfakeplaceholdervalue99\" not in msg\n assert \"SECRETfakeplaceholdervalue99\" not in msg\n assert \"[REDACTED]\" in msg\n assert \"rejected the request (400)\" in msg\n\n def test_redact_secrets_preserves_nonsecret_text(self):\n from decepticon.llm.factory import _redact_secrets\n\n text = \"No fallback model group found for model_group=anthropic/claude-opus-4-7.\"\n assert _redact_secrets(text) == text\n\n\n# ── DeepSeek V4 Pro reasoning_content passthrough ────────────────────────\n\n\nclass TestDeepSeekReasoningContent:\n \"\"\"Verify reasoning_content survives both the non-streaming and streaming\n paths so it can be sent back on subsequent API turns.\"\"\"\n\n def test_create_chat_result_extracts_reasoning_content(self):\n \"\"\"Non-streaming: _create_chat_result captures reasoning_content\n from the response dict's choice message.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Simulate the OpenAI response dict with reasoning_content\n response_dict = {\n \"choices\": [\n {\n \"message\": {\n \"role\": \"assistant\",\n \"content\": \"Four\",\n \"reasoning_content\": \"I think therefore I am\",\n },\n \"finish_reason\": \"stop\",\n }\n ],\n \"usage\": {},\n }\n\n # Mock the parent's _create_chat_result to return a ChatResult without reasoning_content\n msg = AIMessage(content=\"Four\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert (\n result.generations[0].message.additional_kwargs[\"reasoning_content\"]\n == \"I think therefore I am\"\n )\n\n def test_create_chat_result_skips_when_absent(self):\n \"\"\"Non-streaming: no crash when reasoning_content is missing.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n response_dict = {\n \"choices\": [\n {\"message\": {\"role\": \"assistant\", \"content\": \"hi\"}, \"finish_reason\": \"stop\"}\n ],\n \"usage\": {},\n }\n\n msg = AIMessage(content=\"hi\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert \"reasoning_content\" not in result.generations[0].message.additional_kwargs\n\n def test_convert_chunk_injects_reasoning_content(self):\n \"\"\"Streaming: _convert_chunk_to_generation_chunk captures\n reasoning_content from the raw delta and injects it into\n AIMessageChunk.additional_kwargs.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Build a fake raw SSE chunk dict with reasoning_content in the delta\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Let me think...\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n # The parent's _convert_chunk_to_generation_chunk builds the\n # ChatGenerationChunk but ignores reasoning_content. We mock it\n # to return a chunk without reasoning_content, then verify our\n # override injects it.\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n # Bind the real method to our mock instance\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert result.message.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n\n def test_convert_chunk_no_reasoning_leaves_kwargs_clean(self):\n \"\"\"Streaming: when delta has no reasoning_content, additional_kwargs\n is not polluted.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\"role\": \"assistant\", \"content\": \"hi\"},\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_reasoning_content_accumulates_across_chunks(self):\n \"\"\"Streaming: reasoning_content from multiple chunks concatenates\n via AIMessageChunk.__add__ (merge_dicts).\"\"\"\n from langchain_core.messages import AIMessageChunk\n\n c1 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"Let me \"})\n c2 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"think...\"})\n c3 = AIMessageChunk(content=\"Hello!\", additional_kwargs={})\n\n merged = c1 + c2 + c3\n assert merged.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n assert merged.content == \"Hello!\"\n\n def test_get_request_payload_injects_reasoning_content(self):\n \"\"\"Outbound: _get_request_payload injects reasoning_content from\n AIMessage.additional_kwargs into serialized assistant message dicts.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"hi\"),\n AIMessage(\n content=\"hello\",\n additional_kwargs={\"reasoning_content\": \"thinking...\"},\n ),\n ]\n\n # Mock super()._get_request_payload to return a payload without reasoning_content\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"hi\"},\n {\"role\": \"assistant\", \"content\": \"hello\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thinking...\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert payload[\"extra_body\"][\"thinking\"] == {\"type\": \"enabled\"}\n assert payload[\"reasoning_effort\"] == \"high\"\n\n def test_convert_chunk_empty_reasoning_content_ignored(self):\n \"\"\"Streaming: empty string reasoning_content in delta is ignored.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"hi\",\n \"reasoning_content\": \"\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n # Empty string is falsy — should not pollute additional_kwargs\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_none_delta_no_crash(self):\n \"\"\"Streaming: None delta in choices does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": None,\n \"finish_reason\": \"stop\",\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_empty_choices_no_crash(self):\n \"\"\"Streaming: empty choices array does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"choices\": []}\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_parent_returns_none(self):\n \"\"\"Streaming: when parent returns None, we return None too.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"type\": \"content.delta\"} # parent returns None for these\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=None,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is None\n\n def test_convert_chunk_beta_stream_format(self):\n \"\"\"Streaming: handles beta.chat.completions.stream nested chunk format.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Some LangChain versions nest under \"chunk\" key\n raw_chunk = {\n \"chunk\": {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Thinking via beta format...\",\n },\n \"finish_reason\": None,\n }\n ],\n },\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert (\n result.message.additional_kwargs[\"reasoning_content\"] == \"Thinking via beta format...\"\n )\n\n def test_get_request_payload_multiple_assistant_messages(self):\n \"\"\"Outbound: each assistant message gets its own reasoning_content.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"q1\"),\n AIMessage(content=\"a1\", additional_kwargs={\"reasoning_content\": \"thought1\"}),\n HumanMessage(content=\"q2\"),\n AIMessage(content=\"a2\", additional_kwargs={\"reasoning_content\": \"thought2\"}),\n ]\n\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"q1\"},\n {\"role\": \"assistant\", \"content\": \"a1\"},\n {\"role\": \"user\", \"content\": \"q2\"},\n {\"role\": \"assistant\", \"content\": \"a2\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thought1\"\n assert payload[\"messages\"][3][\"reasoning_content\"] == \"thought2\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert \"reasoning_content\" not in payload[\"messages\"][2]\n\n def test_model_detection(self):\n \"\"\"Factory routes DeepSeek V4 (pro + flash) and legacy reasoner.\n\n Per DeepSeek's API docs ``deepseek-reasoner`` is the deprecated\n alias for ``deepseek-v4-flash`` thinking mode, so v4-flash also\n returns ``reasoning_content`` and the API rejects subsequent\n tool turns when the field is omitted. Closes #201, #220.\n \"\"\"\n from decepticon.llm.factory import _model_is_deepseek_thinking\n\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-pro\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-flash\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-reasoner\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-chat\") is False\n assert _model_is_deepseek_thinking(\"openai/gpt-5.5\") is False\n\n\n# ── LLAMACPP_LOCAL credential detection (issue #151) ────────────────────\n\n\n_LLAMACPP_RELATED_ENV = (\n \"LLAMACPP_API_BASE\",\n \"LLAMACPP_MODEL\",\n \"LLAMACPP_API_KEY\",\n)\n\n\ndef _scrub_other_credentials(monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"Delete any env that would be detected as another credential.\n\n The credential-detection tests below need to assert \"only LLAMACPP\n is detected\"; without this scrub, a developer's exported\n ``ANTHROPIC_API_KEY`` would creep in and the assertion would fail\n locally only.\n \"\"\"\n for var in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"GROQ_API_KEY\",\n \"TOGETHER_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"COHERE_API_KEY\",\n \"MOONSHOT_API_KEY\",\n \"ZAI_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"GITHUB_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n ):\n monkeypatch.delenv(var, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n\nclass TestLlamacppLocalConfigured:\n \"\"\"``_llamacpp_local_configured`` is the gate for adding\n ``LLAMACPP_LOCAL`` to the credentials chain. Either env var being\n set is enough — neither requires the other, mirroring the\n LM Studio / Ollama detection contract.\n \"\"\"\n\n def test_returns_false_when_neither_env_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n assert _llamacpp_local_configured() is False\n\n def test_returns_true_when_only_base_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n assert _llamacpp_local_configured() is True\n\n def test_returns_true_when_only_model_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n assert _llamacpp_local_configured() is True\n\n def test_whitespace_does_not_count_as_set(self, monkeypatch):\n \"\"\"Stray ``LLAMACPP_API_BASE=`` lines in .env must not silently\n opt the user in — same fail-soft as the Ollama detector.\"\"\"\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \" \")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"\")\n assert _llamacpp_local_configured() is False\n\n\nclass TestResolveCredentialsForLlamacpp:\n \"\"\"End-to-end check that ``_resolve_credentials`` picks up the\n user's llama.cpp config — both the explicit-priority path and the\n \"only LLAMACPP_* detected\" auto-fallback at the bottom of\n ``_resolve_credentials``.\n \"\"\"\n\n def test_only_llamacpp_detected_returns_llamacpp_only_chain(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL]\n\n def test_priority_list_with_llamacpp_picks_it_up(self, monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"User wrote an explicit DECEPTICON_AUTH_PRIORITY listing\n llamacpp_local — the resolver must respect that ordering.\"\"\"\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen-coder\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"llamacpp_local,anthropic_api\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\nclass TestLLMTimeout:\n \"\"\"Whole-coroutine LLM request timeout (DECEPTICON_LLM_TIMEOUT_SECONDS).\"\"\"\n\n def test_call_with_timeout_raises_typed_exception(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> str:\n await asyncio.sleep(10)\n return \"done\"\n\n with pytest.raises(LLMTimeoutError, match=\"timed out after 0.01 seconds\"):\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n\n def test_call_with_timeout_preserves_original_timeout_as_cause(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> None:\n await asyncio.sleep(10)\n\n try:\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n except LLMTimeoutError as exc:\n assert isinstance(exc.__cause__, asyncio.TimeoutError)\n else:\n raise AssertionError(\"expected LLMTimeoutError\")\n\n def test_call_with_timeout_returns_successful_result(self) -> None:\n from decepticon.llm.factory import call_with_timeout\n\n async def fast_call() -> str:\n await asyncio.sleep(0)\n return \"ok\"\n\n assert asyncio.run(call_with_timeout(fast_call(), 1)) == \"ok\"\n\n def test_call_with_timeout_propagates_non_timeout_exceptions(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def failing_call() -> None:\n raise RuntimeError(\"upstream 500\")\n\n with pytest.raises(RuntimeError, match=\"upstream 500\"):\n asyncio.run(call_with_timeout(failing_call(), 1))\n # And not as a timeout error\n try:\n asyncio.run(call_with_timeout(failing_call(), 1))\n except LLMTimeoutError:\n raise AssertionError(\"non-timeout exception was retyped\")\n except RuntimeError:\n pass\n\n def test_env_override_takes_effect(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"3\")\n assert _resolve_llm_timeout_seconds() == 3.0\n\n def test_default_timeout_is_600_when_env_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.delenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", raising=False)\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_blank_env_falls_back_to_default(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \" \")\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_invalid_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"not-a-number\")\n with pytest.raises(ValueError):\n _resolve_llm_timeout_seconds()\n\n def test_non_positive_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"0\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"-5\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n"}, "2b8c6ac380139d193829a6c1b303a07a946abfbf": {"t": 1785755396.313059, "n": 56626, "h": "b54dff8aaaaa48de", "c": "\"\"\"Unit tests for decepticon.llm.factory.\"\"\"\n\nimport asyncio\nimport logging\n\nimport pytest\n\nfrom decepticon.llm.factory import (\n LLMFactory,\n _is_real_key,\n _llamacpp_local_configured,\n _method_is_configured,\n _oauth_credentials_present,\n _oauth_env_credentials_present,\n _resolve_credentials,\n)\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\n\n\nclass TestIsRealKey:\n \"\"\"Vendor-aware API key validation.\n\n The launcher writes ``your-…-key-here`` placeholders into .env so the\n user can later swap in a real key. The factory needs to reject those\n plus any obvious junk (short strings, ``placeholder``/``not-used``\n markers, vendor-prefix mismatches) so the credentials inventory\n reflects what actually works at request time.\n \"\"\"\n\n def test_rejects_empty_and_placeholder_template(self) -> None:\n assert _is_real_key(\"\") is False\n assert _is_real_key(\" \") is False\n assert _is_real_key(\"your-anthropic-key-here\") is False\n assert _is_real_key(\"YOUR-OPENAI-KEY-HERE\") is False # case-insensitive\n\n def test_rejects_short_strings(self) -> None:\n # Under 24 chars — every vendor-issued key exceeds this.\n assert _is_real_key(\"sk-tooshort\") is False\n\n def test_rejects_placeholder_tokens_in_value(self) -> None:\n long_enough = \"x\" * 30\n for token in (\"placeholder\", \"not-used\", \"dummy\", \"fake\", \"example\"):\n assert _is_real_key(f\"sk-{token}-{long_enough}\") is False, token\n\n def test_accepts_realistic_keys_without_method(self) -> None:\n # Without method context, prefix check is skipped.\n assert _is_real_key(\"sk-ant-api03-realtokenfortestingauthrouting12345\") is True\n assert _is_real_key(\"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\") is True\n\n def test_rejects_wrong_vendor_prefix(self) -> None:\n # An OpenAI-shaped key in the Anthropic slot must be caught.\n openai_key = \"sk-proj-realopenaitokenfortestingauthrouting12345\"\n assert _is_real_key(openai_key, AuthMethod.ANTHROPIC_API) is False\n # …and vice versa.\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.GOOGLE_API) is False\n\n def test_accepts_correct_vendor_prefix(self) -> None:\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.ANTHROPIC_API) is True\n google_key = \"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\"\n assert _is_real_key(google_key, AuthMethod.GOOGLE_API) is True\n\n\nclass TestOAuthCredentialsPresent:\n \"\"\"OAuth detection requires the credential file alongside the boolean.\n\n Without the file check, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place OAuth in\n every fallback chain and generate one 401 per request.\n \"\"\"\n\n def test_returns_false_when_file_missing(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(tmp_path / \"absent.json\"))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_when_file_is_empty(self, monkeypatch, tmp_path) -> None:\n # ``/dev/null``-style mounts read as empty — must fail closed.\n empty = tmp_path / \"empty.json\"\n empty.write_text(\"\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(empty))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_on_invalid_json(self, monkeypatch, tmp_path) -> None:\n bad = tmp_path / \"bad.json\"\n bad.write_text(\"{not-json\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(bad))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_true_when_file_is_well_formed(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"credentials.json\"\n good.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeef\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is True\n\n def test_codex_path_via_env_override(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"auth.json\"\n good.write_text('{\"tokens\": {\"access_token\": \"ABC\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CODEX_AUTH_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.OPENAI_OAUTH) is True\n\n\nclass TestOAuthEnvCredentials:\n \"\"\"B12: env-backed OAuth creds count as configured, not just on-disk files.\n Before the fix, ``_method_is_configured`` required a credential *file* even\n though the LiteLLM handlers also accept env-var tokens (GEMINI_ACCESS_TOKEN,\n GROK_SESSION_TOKEN, COPILOT_REFRESH_TOKEN, …). Env-only setups were silently\n dropped from the fallback chain.\n \"\"\"\n\n def test_env_present_returns_true_for_each_method(self, monkeypatch) -> None:\n cases = {\n AuthMethod.GOOGLE_OAUTH: \"GEMINI_ACCESS_TOKEN\",\n AuthMethod.COPILOT_OAUTH: \"COPILOT_REFRESH_TOKEN\",\n AuthMethod.GROK_OAUTH: \"GROK_SESSION_TOKEN\",\n AuthMethod.PERPLEXITY_OAUTH: \"PERPLEXITY_SESSION_TOKEN\",\n AuthMethod.ANTHROPIC_OAUTH: \"ANTHROPIC_OAUTH_TOKEN\",\n }\n for method, env_var in cases.items():\n monkeypatch.setenv(env_var, \"tok-from-env\")\n assert _oauth_env_credentials_present(method) is True\n monkeypatch.delenv(env_var, raising=False)\n\n def test_env_only_method_is_configured_without_file(self, monkeypatch, tmp_path) -> None:\n # Flag true + env token, but the credential file is absent.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.setenv(\"DECEPTICON_AUTH_GEMINI\", \"true\")\n monkeypatch.setenv(\"GEMINI_ACCESS_TOKEN\", \"ya29.env-token\")\n assert _oauth_credentials_present(AuthMethod.GOOGLE_OAUTH) is False\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is True\n\n def test_method_not_configured_without_file_or_env(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.setenv(\"DECEPTICON_AUTH_GEMINI\", \"true\")\n monkeypatch.delenv(\"GEMINI_ACCESS_TOKEN\", raising=False)\n monkeypatch.delenv(\"GEMINI_SESSION_COOKIES\", raising=False)\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is False\n\n def test_env_creds_still_require_intent_flag(self, monkeypatch, tmp_path) -> None:\n # Env token present but the boolean intent flag is off → not configured.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.delenv(\"DECEPTICON_AUTH_GEMINI\", raising=False)\n monkeypatch.setenv(\"GEMINI_ACCESS_TOKEN\", \"ya29.env-token\")\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is False\n\n def test_codex_has_no_env_token_override(self, monkeypatch) -> None:\n # Codex authenticates only from its file; no env-token override exists.\n monkeypatch.setenv(\"CODEX_ACCESS_TOKEN\", \"should-not-count\")\n assert _oauth_env_credentials_present(AuthMethod.OPENAI_OAUTH) is False\n\n\nclass TestLLMFactory:\n def setup_method(self):\n self.proxy = ProxyConfig(url=\"http://localhost:4000\", api_key=\"test-key\")\n # Build an explicit mapping so the test doesn't depend on env vars.\n creds = Credentials.all_api_methods()\n self.mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n self.factory = LLMFactory(self.proxy, self.mapping)\n\n def test_factory_initializes(self):\n assert self.factory.proxy_url == \"http://localhost:4000\"\n\n def test_get_model_returns_chat_model(self):\n model = self.factory.get_model(\"recon\")\n assert model is not None\n assert model.model_name == \"anthropic/claude-haiku-4-5\"\n\n def test_get_model_caches_instances(self):\n m1 = self.factory.get_model(\"recon\")\n m2 = self.factory.get_model(\"recon\")\n assert m1 is m2\n\n def test_get_model_different_roles_different_models(self):\n recon = self.factory.get_model(\"recon\")\n decepticon = self.factory.get_model(\"decepticon\")\n assert recon is not decepticon\n assert recon.model_name != decepticon.model_name\n\n def test_get_model_unknown_role_raises(self):\n with pytest.raises(KeyError, match=\"No model assignment\"):\n self.factory.get_model(\"nonexistent\")\n\n def test_router_accessible(self):\n assert self.factory.router is not None\n\n def test_get_fallback_models_full_chain(self):\n models = self.factory.get_fallback_models(\"recon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5-nano\",\n \"gemini/gemini-2.5-flash-lite\",\n \"deepseek/deepseek-v4-flash\",\n \"openrouter/anthropic/claude-haiku-4-5\",\n \"nvidia_nim/meta/llama-3.2-3b-instruct\",\n ]\n\n def test_get_fallback_models_high_tier_includes_all_methods(self):\n models = self.factory.get_fallback_models(\"decepticon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5.5\",\n \"gemini/gemini-2.5-pro\",\n \"minimax/MiniMax-M2.5\",\n \"deepseek/deepseek-v4-pro\",\n \"xai/grok-4.3\",\n \"mistral/mistral-large-latest\",\n \"openrouter/anthropic/claude-opus-4-7\",\n \"nvidia_nim/meta/llama-3.3-70b-instruct\",\n ]\n\n def test_get_fallback_models_without_fallback(self):\n # Single-credential mapping → no fallback.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n factory = LLMFactory(self.proxy, mapping)\n assert factory.get_fallback_models(\"recon\") == []\n\n def test_explicit_credentials_param(self):\n # Constructor accepts a Credentials object instead of a full mapping.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n factory = LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n assert factory.get_model(\"decepticon\").model_name == \"openai/gpt-5.5\"\n\n\nclass TestLLMFactoryHealthCheck:\n def test_health_check_returns_false_when_no_proxy(self):\n proxy = ProxyConfig(url=\"http://localhost:19999\")\n factory = LLMFactory(proxy, mapping=LLMModelMapping())\n assert asyncio.run(factory.health_check()) is False\n\n\nclass TestResolveCredentials:\n def test_real_keys_only(self, monkeypatch):\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"your-openai-key-here\") # placeholder\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_API]\n\n def test_oauth_only(self, monkeypatch, tmp_path):\n # OAuth detection requires the credential FILE alongside the\n # boolean — point Claude Code at a temp credentials file so the\n # test runs deterministically regardless of host state.\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_OAUTH]\n\n def test_oauth_flag_without_credential_file_is_dropped(self, monkeypatch, tmp_path):\n \"\"\"Stale ``DECEPTICON_AUTH_CLAUDE_CODE=true`` after ``codex logout``\n (or after the user deleted ``~/.claude/.credentials.json``) must\n not place the OAuth method into the chain — otherwise every\n request 401s before falling back to the next provider.\n \"\"\"\n # Point both the primary and legacy fallback paths at tmp_path so\n # any ``~/.claude/.credentials.json`` or\n # ``~/.config/anthropic/q/tokens.json`` on the dev host doesn't\n # accidentally satisfy the file-presence check.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n missing = tmp_path / \"missing.json\" # never created\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n creds = _resolve_credentials()\n assert AuthMethod.ANTHROPIC_OAUTH not in creds.methods\n\n def test_oauth_plus_api_priority_default(self, monkeypatch, tmp_path):\n # Default priority is anthropic_oauth > anthropic_api > openai_api ...\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n ]\n\n def test_explicit_priority_override(self, monkeypatch):\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"openai_api,anthropic_api\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n monkeypatch.delenv(\"GEMINI_API_KEY\", raising=False)\n monkeypatch.delenv(\"MINIMAX_API_KEY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OPENAI_API, AuthMethod.ANTHROPIC_API]\n\n def test_placeholder_falls_back_to_all_api_methods(self, monkeypatch):\n \"\"\"When every detected method is a placeholder/missing, the resolver\n falls back to the all-API-methods inventory so module-level agent\n constructors stay importable in CI / dev shells without keys.\"\"\"\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"your-anthropic-key-here\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n ]\n\n def test_explicit_priority_no_creds_logs_error(self, monkeypatch, tmp_path, caplog):\n \"\"\"An explicit ``DECEPTICON_AUTH_PRIORITY`` whose every listed\n method fails detection (typical of a broken OAuth credentials\n mount — e.g. ``CLAUDE_CREDENTIALS_VOLUME`` unset and the\n compose file bound ``/dev/null`` into the container) must\n surface the root cause at ERROR level.\n\n Otherwise the silent fallback to ``all_api_methods()`` runs\n through nine unrelated providers and the operator only sees a\n downstream 401-cascade — confusingly masked as a 429 once the\n routed-to provider (e.g. NVIDIA NIM) cools down. Backward\n compat is preserved: the resolver still returns\n ``all_api_methods()`` so module imports remain green; the\n real model call surfaces a separate, actionable error via\n ``_reraise_with_actionable_message``.\n \"\"\"\n # Point the OAuth credential path at a file we never create so\n # detection fails the same way ``/dev/null`` does at runtime.\n missing = tmp_path / \"missing.json\"\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"anthropic_oauth\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n # ``decepticon_core.utils.logging`` sets the parent decepticon logger\n # to ``propagate=False`` so library output doesn't double up in\n # apps that own their root handler. caplog hooks the root logger,\n # so without re-enabling propagation our ERROR record never\n # reaches pytest's capture buffer. Re-enable for the duration of\n # the call and let monkeypatch restore on teardown.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.ERROR):\n creds = _resolve_credentials()\n\n # Backward compat: still returns the all-API-methods fallback.\n assert creds.methods == Credentials.all_api_methods().methods\n\n # ERROR diagnostic must mention the env var name so an operator\n # scanning ``decepticon logs langgraph`` knows where to look.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert any(\"DECEPTICON_AUTH_PRIORITY\" in r.getMessage() for r in error_records), (\n f\"No ERROR log mentioned DECEPTICON_AUTH_PRIORITY. \"\n f\"Got: {[r.getMessage() for r in error_records]}\"\n )\n\n def test_implicit_priority_no_creds_stays_info_level(self, monkeypatch, caplog):\n \"\"\"Implicit priority (no DECEPTICON_AUTH_PRIORITY set) with no\n detectable credentials is the import-friendly CI/test path:\n we keep the existing INFO log and ``all_api_methods()`` return\n so module imports work without keys present. Only **explicit**\n priority with no matches gets escalated to ERROR.\n \"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n\n # Match the explicit-priority test's pattern so caplog sees the\n # records — the decepticon parent logger defaults to\n # ``propagate=False`` per ``decepticon_core.utils.logging``.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.DEBUG):\n creds = _resolve_credentials()\n\n assert creds.methods == Credentials.all_api_methods().methods\n # No ERROR records — this case stays informational.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert not error_records, (\n f\"Unexpected ERROR logs in implicit-priority path: \"\n f\"{[r.getMessage() for r in error_records]}\"\n )\n\n def test_ollama_local_only_returns_ollama_chain(self, monkeypatch):\n \"\"\"Issue #106: a user with only OLLAMA_API_BASE / OLLAMA_MODEL set\n (no API keys, no OAuth) must get a chain of one — Ollama only.\n Falling back to all-API-methods would produce 401 errors on every\n provider the user doesn't have.\"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL]\n\n def test_explicit_priority_with_ollama_local(self, monkeypatch):\n \"\"\"User opts into Ollama via explicit priority — the resolver\n recognizes it as configured when OLLAMA_API_BASE is set.\"\"\"\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"ollama_local,anthropic_api\")\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\n# ── Temperature handling (issue #107) ───────────────────────────────────\n\n\nclass TestTemperatureDrop:\n \"\"\"Claude Opus 4.7 rejects ``temperature`` regardless of route. The\n factory must drop it on every Opus 4 surface (anthropic/, auth/,\n openrouter/anthropic/) and keep it for everyone else.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _model_drops_temperature\n\n self._drops = _model_drops_temperature\n\n def test_anthropic_opus_drops_temperature(self):\n assert self._drops(\"anthropic/claude-opus-4-7\") is True\n\n def test_oauth_opus_drops_temperature(self):\n assert self._drops(\"auth/claude-opus-4-7\") is True\n\n def test_openrouter_opus_drops_temperature(self):\n assert self._drops(\"openrouter/anthropic/claude-opus-4-7\") is True\n\n def test_sonnet_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-sonnet-4-6\") is False\n\n def test_haiku_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-haiku-4-5\") is False\n\n def test_openai_keeps_temperature(self):\n assert self._drops(\"openai/gpt-5.5\") is False\n\n def test_ollama_keeps_temperature(self):\n assert self._drops(\"ollama_chat/qwen3-coder:30b\") is False\n\n\n# ── Actionable error translation (issue #107 + community feedback) ──────\n\n\nclass TestActionableErrorTranslation:\n \"\"\"The OSS user complaint: every upstream failure surfaces as 'An\n internal error occurred', stripping the message that would tell them\n what to fix. Each branch below verifies one class of error gets\n rewritten with a remediation hint and the model id that hit the\n failure.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _reraise_with_actionable_message\n\n self._translate = _reraise_with_actionable_message\n\n def test_no_fallback_model_group_branch(self):\n exc = Exception(\n \"litellm.BadRequestError: ... No fallback model group found \"\n \"for original model_group=anthropic/claude-opus-4-7.\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"no provider fallback\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_400_bad_request_branch(self):\n # openai.BadRequestError carries 'Error code: 400' in repr.\n exc = Exception(\"Error code: 400 - {'error': {'message': 'temperature is deprecated'}}\")\n type(exc).__name__ # noqa: B018 — sanity, ensures Exception default name\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n assert \"rejected the request (400)\" in str(info.value)\n\n def test_401_authentication_branch(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\"Error code: 401 - invalid_api_key\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"credentials (401)\" in msg\n assert \"decepticon onboard --reset\" in msg\n\n def test_429_ratelimit_branch(self):\n exc = type(\"RateLimitError\", (Exception,), {})(\"Error code: 429\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"rate limit (429)\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_404_notfound_with_ollama_hint(self):\n exc = type(\"NotFoundError\", (Exception,), {})(\"Error code: 404 - model not found\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"ollama_chat/nonexistent\")\n msg = str(info.value)\n assert \"404\" in msg\n assert \"OLLAMA_MODEL\" in msg\n\n def test_unmatched_error_passes_through(self):\n # Anything we don't recognize must NOT raise — the caller's\n # ``raise`` follows and re-raises the original exception with\n # full traceback.\n exc = ValueError(\"something completely unrelated\")\n # Should not raise from the helper.\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n\n def test_401_redacts_bearer_token_keeps_guidance(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\n \"Error code: 401 - {'error': 'invalid_api_key'} \"\n \"Authorization: Bearer sk-ant-SECRET123FAKEPLACEHOLDERTOKEN\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"sk-ant-SECRET123FAKEPLACEHOLDERTOKEN\" not in msg\n assert \"SECRET123FAKEPLACEHOLDERTOKEN\" not in msg\n assert \"[REDACTED]\" in msg\n assert \"credentials (401)\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n\n def test_400_redacts_api_key_kwarg_keeps_guidance(self):\n exc = Exception(\n \"Error code: 400 - bad request from provider with api_key=sk-SECRETfakeplaceholdervalue99\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"sk-SECRETfakeplaceholdervalue99\" not in msg\n assert \"SECRETfakeplaceholdervalue99\" not in msg\n assert \"[REDACTED]\" in msg\n assert \"rejected the request (400)\" in msg\n\n def test_redact_secrets_preserves_nonsecret_text(self):\n from decepticon.llm.factory import _redact_secrets\n\n text = \"No fallback model group found for model_group=anthropic/claude-opus-4-7.\"\n assert _redact_secrets(text) == text\n\n\n# ── DeepSeek V4 Pro reasoning_content passthrough ────────────────────────\n\n\nclass TestDeepSeekReasoningContent:\n \"\"\"Verify reasoning_content survives both the non-streaming and streaming\n paths so it can be sent back on subsequent API turns.\"\"\"\n\n def test_create_chat_result_extracts_reasoning_content(self):\n \"\"\"Non-streaming: _create_chat_result captures reasoning_content\n from the response dict's choice message.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Simulate the OpenAI response dict with reasoning_content\n response_dict = {\n \"choices\": [\n {\n \"message\": {\n \"role\": \"assistant\",\n \"content\": \"Four\",\n \"reasoning_content\": \"I think therefore I am\",\n },\n \"finish_reason\": \"stop\",\n }\n ],\n \"usage\": {},\n }\n\n # Mock the parent's _create_chat_result to return a ChatResult without reasoning_content\n msg = AIMessage(content=\"Four\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert (\n result.generations[0].message.additional_kwargs[\"reasoning_content\"]\n == \"I think therefore I am\"\n )\n\n def test_create_chat_result_skips_when_absent(self):\n \"\"\"Non-streaming: no crash when reasoning_content is missing.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n response_dict = {\n \"choices\": [\n {\"message\": {\"role\": \"assistant\", \"content\": \"hi\"}, \"finish_reason\": \"stop\"}\n ],\n \"usage\": {},\n }\n\n msg = AIMessage(content=\"hi\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert \"reasoning_content\" not in result.generations[0].message.additional_kwargs\n\n def test_convert_chunk_injects_reasoning_content(self):\n \"\"\"Streaming: _convert_chunk_to_generation_chunk captures\n reasoning_content from the raw delta and injects it into\n AIMessageChunk.additional_kwargs.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Build a fake raw SSE chunk dict with reasoning_content in the delta\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Let me think...\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n # The parent's _convert_chunk_to_generation_chunk builds the\n # ChatGenerationChunk but ignores reasoning_content. We mock it\n # to return a chunk without reasoning_content, then verify our\n # override injects it.\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n # Bind the real method to our mock instance\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert result.message.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n\n def test_convert_chunk_no_reasoning_leaves_kwargs_clean(self):\n \"\"\"Streaming: when delta has no reasoning_content, additional_kwargs\n is not polluted.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\"role\": \"assistant\", \"content\": \"hi\"},\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_reasoning_content_accumulates_across_chunks(self):\n \"\"\"Streaming: reasoning_content from multiple chunks concatenates\n via AIMessageChunk.__add__ (merge_dicts).\"\"\"\n from langchain_core.messages import AIMessageChunk\n\n c1 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"Let me \"})\n c2 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"think...\"})\n c3 = AIMessageChunk(content=\"Hello!\", additional_kwargs={})\n\n merged = c1 + c2 + c3\n assert merged.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n assert merged.content == \"Hello!\"\n\n def test_get_request_payload_injects_reasoning_content(self):\n \"\"\"Outbound: _get_request_payload injects reasoning_content from\n AIMessage.additional_kwargs into serialized assistant message dicts.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"hi\"),\n AIMessage(\n content=\"hello\",\n additional_kwargs={\"reasoning_content\": \"thinking...\"},\n ),\n ]\n\n # Mock super()._get_request_payload to return a payload without reasoning_content\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"hi\"},\n {\"role\": \"assistant\", \"content\": \"hello\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thinking...\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert payload[\"extra_body\"][\"thinking\"] == {\"type\": \"enabled\"}\n assert payload[\"reasoning_effort\"] == \"high\"\n\n def test_convert_chunk_empty_reasoning_content_ignored(self):\n \"\"\"Streaming: empty string reasoning_content in delta is ignored.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"hi\",\n \"reasoning_content\": \"\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n # Empty string is falsy — should not pollute additional_kwargs\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_none_delta_no_crash(self):\n \"\"\"Streaming: None delta in choices does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": None,\n \"finish_reason\": \"stop\",\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_empty_choices_no_crash(self):\n \"\"\"Streaming: empty choices array does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"choices\": []}\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_parent_returns_none(self):\n \"\"\"Streaming: when parent returns None, we return None too.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"type\": \"content.delta\"} # parent returns None for these\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=None,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is None\n\n def test_convert_chunk_beta_stream_format(self):\n \"\"\"Streaming: handles beta.chat.completions.stream nested chunk format.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Some LangChain versions nest under \"chunk\" key\n raw_chunk = {\n \"chunk\": {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Thinking via beta format...\",\n },\n \"finish_reason\": None,\n }\n ],\n },\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert (\n result.message.additional_kwargs[\"reasoning_content\"] == \"Thinking via beta format...\"\n )\n\n def test_get_request_payload_multiple_assistant_messages(self):\n \"\"\"Outbound: each assistant message gets its own reasoning_content.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"q1\"),\n AIMessage(content=\"a1\", additional_kwargs={\"reasoning_content\": \"thought1\"}),\n HumanMessage(content=\"q2\"),\n AIMessage(content=\"a2\", additional_kwargs={\"reasoning_content\": \"thought2\"}),\n ]\n\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"q1\"},\n {\"role\": \"assistant\", \"content\": \"a1\"},\n {\"role\": \"user\", \"content\": \"q2\"},\n {\"role\": \"assistant\", \"content\": \"a2\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thought1\"\n assert payload[\"messages\"][3][\"reasoning_content\"] == \"thought2\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert \"reasoning_content\" not in payload[\"messages\"][2]\n\n def test_model_detection(self):\n \"\"\"Factory routes DeepSeek V4 (pro + flash) and legacy reasoner.\n\n Per DeepSeek's API docs ``deepseek-reasoner`` is the deprecated\n alias for ``deepseek-v4-flash`` thinking mode, so v4-flash also\n returns ``reasoning_content`` and the API rejects subsequent\n tool turns when the field is omitted. Closes #201, #220.\n \"\"\"\n from decepticon.llm.factory import _model_is_deepseek_thinking\n\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-pro\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-flash\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-reasoner\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-chat\") is False\n assert _model_is_deepseek_thinking(\"openai/gpt-5.5\") is False\n\n\n# ── LLAMACPP_LOCAL credential detection (issue #151) ────────────────────\n\n\n_LLAMACPP_RELATED_ENV = (\n \"LLAMACPP_API_BASE\",\n \"LLAMACPP_MODEL\",\n \"LLAMACPP_API_KEY\",\n)\n\n\ndef _scrub_other_credentials(monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"Delete any env that would be detected as another credential.\n\n The credential-detection tests below need to assert \"only LLAMACPP\n is detected\"; without this scrub, a developer's exported\n ``ANTHROPIC_API_KEY`` would creep in and the assertion would fail\n locally only.\n \"\"\"\n for var in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"GROQ_API_KEY\",\n \"TOGETHER_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"COHERE_API_KEY\",\n \"MOONSHOT_API_KEY\",\n \"ZAI_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"GITHUB_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n ):\n monkeypatch.delenv(var, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n\nclass TestLlamacppLocalConfigured:\n \"\"\"``_llamacpp_local_configured`` is the gate for adding\n ``LLAMACPP_LOCAL`` to the credentials chain. Either env var being\n set is enough — neither requires the other, mirroring the\n LM Studio / Ollama detection contract.\n \"\"\"\n\n def test_returns_false_when_neither_env_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n assert _llamacpp_local_configured() is False\n\n def test_returns_true_when_only_base_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n assert _llamacpp_local_configured() is True\n\n def test_returns_true_when_only_model_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n assert _llamacpp_local_configured() is True\n\n def test_whitespace_does_not_count_as_set(self, monkeypatch):\n \"\"\"Stray ``LLAMACPP_API_BASE=`` lines in .env must not silently\n opt the user in — same fail-soft as the Ollama detector.\"\"\"\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \" \")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"\")\n assert _llamacpp_local_configured() is False\n\n\nclass TestResolveCredentialsForLlamacpp:\n \"\"\"End-to-end check that ``_resolve_credentials`` picks up the\n user's llama.cpp config — both the explicit-priority path and the\n \"only LLAMACPP_* detected\" auto-fallback at the bottom of\n ``_resolve_credentials``.\n \"\"\"\n\n def test_only_llamacpp_detected_returns_llamacpp_only_chain(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL]\n\n def test_priority_list_with_llamacpp_picks_it_up(self, monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"User wrote an explicit DECEPTICON_AUTH_PRIORITY listing\n llamacpp_local — the resolver must respect that ordering.\"\"\"\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen-coder\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"llamacpp_local,anthropic_api\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\nclass TestLLMTimeout:\n \"\"\"Whole-coroutine LLM request timeout (DECEPTICON_LLM_TIMEOUT_SECONDS).\"\"\"\n\n def test_call_with_timeout_raises_typed_exception(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> str:\n await asyncio.sleep(10)\n return \"done\"\n\n with pytest.raises(LLMTimeoutError, match=\"timed out after 0.01 seconds\"):\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n\n def test_call_with_timeout_preserves_original_timeout_as_cause(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> None:\n await asyncio.sleep(10)\n\n try:\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n except LLMTimeoutError as exc:\n assert isinstance(exc.__cause__, asyncio.TimeoutError)\n else:\n raise AssertionError(\"expected LLMTimeoutError\")\n\n def test_call_with_timeout_returns_successful_result(self) -> None:\n from decepticon.llm.factory import call_with_timeout\n\n async def fast_call() -> str:\n await asyncio.sleep(0)\n return \"ok\"\n\n assert asyncio.run(call_with_timeout(fast_call(), 1)) == \"ok\"\n\n def test_call_with_timeout_propagates_non_timeout_exceptions(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def failing_call() -> None:\n raise RuntimeError(\"upstream 500\")\n\n with pytest.raises(RuntimeError, match=\"upstream 500\"):\n asyncio.run(call_with_timeout(failing_call(), 1))\n # And not as a timeout error\n try:\n asyncio.run(call_with_timeout(failing_call(), 1))\n except LLMTimeoutError:\n raise AssertionError(\"non-timeout exception was retyped\")\n except RuntimeError:\n pass\n\n def test_env_override_takes_effect(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"3\")\n assert _resolve_llm_timeout_seconds() == 3.0\n\n def test_default_timeout_is_600_when_env_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.delenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", raising=False)\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_blank_env_falls_back_to_default(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \" \")\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_invalid_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"not-a-number\")\n with pytest.raises(ValueError):\n _resolve_llm_timeout_seconds()\n\n def test_non_positive_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"0\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"-5\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n"}, "335d42b99ceaf02adee4a24dfc6439c0131fa527": {"t": 1785755397.9137907, "n": 53649, "h": "91343d72ad3ac3d2", "c": "\"\"\"Unit tests for decepticon.llm.factory.\"\"\"\n\nimport asyncio\nimport logging\n\nimport pytest\n\nfrom decepticon.llm.factory import (\n LLMFactory,\n _is_real_key,\n _llamacpp_local_configured,\n _oauth_credentials_present,\n _resolve_credentials,\n)\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\n\n\nclass TestIsRealKey:\n \"\"\"Vendor-aware API key validation.\n\n The launcher writes ``your-…-key-here`` placeholders into .env so the\n user can later swap in a real key. The factory needs to reject those\n plus any obvious junk (short strings, ``placeholder``/``not-used``\n markers, vendor-prefix mismatches) so the credentials inventory\n reflects what actually works at request time.\n \"\"\"\n\n def test_rejects_empty_and_placeholder_template(self) -> None:\n assert _is_real_key(\"\") is False\n assert _is_real_key(\" \") is False\n assert _is_real_key(\"your-anthropic-key-here\") is False\n assert _is_real_key(\"YOUR-OPENAI-KEY-HERE\") is False # case-insensitive\n\n def test_rejects_short_strings(self) -> None:\n # Under 24 chars — every vendor-issued key exceeds this.\n assert _is_real_key(\"sk-tooshort\") is False\n\n def test_rejects_placeholder_tokens_in_value(self) -> None:\n long_enough = \"x\" * 30\n for token in (\"placeholder\", \"not-used\", \"dummy\", \"fake\", \"example\"):\n assert _is_real_key(f\"sk-{token}-{long_enough}\") is False, token\n\n def test_accepts_realistic_keys_without_method(self) -> None:\n # Without method context, prefix check is skipped.\n assert _is_real_key(\"sk-ant-api03-realtokenfortestingauthrouting12345\") is True\n assert _is_real_key(\"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\") is True\n\n def test_rejects_wrong_vendor_prefix(self) -> None:\n # An OpenAI-shaped key in the Anthropic slot must be caught.\n openai_key = \"sk-proj-realopenaitokenfortestingauthrouting12345\"\n assert _is_real_key(openai_key, AuthMethod.ANTHROPIC_API) is False\n # …and vice versa.\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.GOOGLE_API) is False\n\n def test_accepts_correct_vendor_prefix(self) -> None:\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.ANTHROPIC_API) is True\n google_key = \"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\"\n assert _is_real_key(google_key, AuthMethod.GOOGLE_API) is True\n\n\nclass TestOAuthCredentialsPresent:\n \"\"\"OAuth detection requires the credential file alongside the boolean.\n\n Without the file check, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place OAuth in\n every fallback chain and generate one 401 per request.\n \"\"\"\n\n def test_returns_false_when_file_missing(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(tmp_path / \"absent.json\"))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_when_file_is_empty(self, monkeypatch, tmp_path) -> None:\n # ``/dev/null``-style mounts read as empty — must fail closed.\n empty = tmp_path / \"empty.json\"\n empty.write_text(\"\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(empty))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_on_invalid_json(self, monkeypatch, tmp_path) -> None:\n bad = tmp_path / \"bad.json\"\n bad.write_text(\"{not-json\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(bad))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_true_when_file_is_well_formed(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"credentials.json\"\n good.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeef\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is True\n\n def test_codex_path_via_env_override(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"auth.json\"\n good.write_text('{\"tokens\": {\"access_token\": \"ABC\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CODEX_AUTH_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.OPENAI_OAUTH) is True\n\n\nclass TestLLMFactory:\n def setup_method(self):\n self.proxy = ProxyConfig(url=\"http://localhost:4000\", api_key=\"test-key\")\n # Build an explicit mapping so the test doesn't depend on env vars.\n creds = Credentials.all_api_methods()\n self.mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n self.factory = LLMFactory(self.proxy, self.mapping)\n\n def test_factory_initializes(self):\n assert self.factory.proxy_url == \"http://localhost:4000\"\n\n def test_get_model_returns_chat_model(self):\n model = self.factory.get_model(\"recon\")\n assert model is not None\n assert model.model_name == \"anthropic/claude-haiku-4-5\"\n\n def test_get_model_caches_instances(self):\n m1 = self.factory.get_model(\"recon\")\n m2 = self.factory.get_model(\"recon\")\n assert m1 is m2\n\n def test_get_model_different_roles_different_models(self):\n recon = self.factory.get_model(\"recon\")\n decepticon = self.factory.get_model(\"decepticon\")\n assert recon is not decepticon\n assert recon.model_name != decepticon.model_name\n\n def test_get_model_unknown_role_raises(self):\n with pytest.raises(KeyError, match=\"No model assignment\"):\n self.factory.get_model(\"nonexistent\")\n\n def test_router_accessible(self):\n assert self.factory.router is not None\n\n def test_get_fallback_models_full_chain(self):\n models = self.factory.get_fallback_models(\"recon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5-nano\",\n \"gemini/gemini-2.5-flash-lite\",\n \"deepseek/deepseek-v4-flash\",\n \"openrouter/anthropic/claude-haiku-4-5\",\n \"nvidia_nim/meta/llama-3.2-3b-instruct\",\n ]\n\n def test_get_fallback_models_high_tier_includes_all_methods(self):\n models = self.factory.get_fallback_models(\"decepticon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5.5\",\n \"gemini/gemini-2.5-pro\",\n \"minimax/MiniMax-M2.5\",\n \"deepseek/deepseek-v4-pro\",\n \"xai/grok-4.3\",\n \"mistral/mistral-large-latest\",\n \"openrouter/anthropic/claude-opus-4-7\",\n \"nvidia_nim/meta/llama-3.3-70b-instruct\",\n ]\n\n def test_get_fallback_models_without_fallback(self):\n # Single-credential mapping → no fallback.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n factory = LLMFactory(self.proxy, mapping)\n assert factory.get_fallback_models(\"recon\") == []\n\n def test_explicit_credentials_param(self):\n # Constructor accepts a Credentials object instead of a full mapping.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n factory = LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n assert factory.get_model(\"decepticon\").model_name == \"openai/gpt-5.5\"\n\n\nclass TestLLMFactoryHealthCheck:\n def test_health_check_returns_false_when_no_proxy(self):\n proxy = ProxyConfig(url=\"http://localhost:19999\")\n factory = LLMFactory(proxy, mapping=LLMModelMapping())\n assert asyncio.run(factory.health_check()) is False\n\n\nclass TestResolveCredentials:\n def test_real_keys_only(self, monkeypatch):\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"your-openai-key-here\") # placeholder\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_API]\n\n def test_oauth_only(self, monkeypatch, tmp_path):\n # OAuth detection requires the credential FILE alongside the\n # boolean — point Claude Code at a temp credentials file so the\n # test runs deterministically regardless of host state.\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_OAUTH]\n\n def test_oauth_flag_without_credential_file_is_dropped(self, monkeypatch, tmp_path):\n \"\"\"Stale ``DECEPTICON_AUTH_CLAUDE_CODE=true`` after ``codex logout``\n (or after the user deleted ``~/.claude/.credentials.json``) must\n not place the OAuth method into the chain — otherwise every\n request 401s before falling back to the next provider.\n \"\"\"\n # Point both the primary and legacy fallback paths at tmp_path so\n # any ``~/.claude/.credentials.json`` or\n # ``~/.config/anthropic/q/tokens.json`` on the dev host doesn't\n # accidentally satisfy the file-presence check.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n missing = tmp_path / \"missing.json\" # never created\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n creds = _resolve_credentials()\n assert AuthMethod.ANTHROPIC_OAUTH not in creds.methods\n\n def test_oauth_plus_api_priority_default(self, monkeypatch, tmp_path):\n # Default priority is anthropic_oauth > anthropic_api > openai_api ...\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n ]\n\n def test_explicit_priority_override(self, monkeypatch):\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"openai_api,anthropic_api\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n monkeypatch.delenv(\"GEMINI_API_KEY\", raising=False)\n monkeypatch.delenv(\"MINIMAX_API_KEY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OPENAI_API, AuthMethod.ANTHROPIC_API]\n\n def test_placeholder_falls_back_to_all_api_methods(self, monkeypatch):\n \"\"\"When every detected method is a placeholder/missing, the resolver\n falls back to the all-API-methods inventory so module-level agent\n constructors stay importable in CI / dev shells without keys.\"\"\"\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"your-anthropic-key-here\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n ]\n\n def test_explicit_priority_no_creds_logs_error(self, monkeypatch, tmp_path, caplog):\n \"\"\"An explicit ``DECEPTICON_AUTH_PRIORITY`` whose every listed\n method fails detection (typical of a broken OAuth credentials\n mount — e.g. ``CLAUDE_CREDENTIALS_VOLUME`` unset and the\n compose file bound ``/dev/null`` into the container) must\n surface the root cause at ERROR level.\n\n Otherwise the silent fallback to ``all_api_methods()`` runs\n through nine unrelated providers and the operator only sees a\n downstream 401-cascade — confusingly masked as a 429 once the\n routed-to provider (e.g. NVIDIA NIM) cools down. Backward\n compat is preserved: the resolver still returns\n ``all_api_methods()`` so module imports remain green; the\n real model call surfaces a separate, actionable error via\n ``_reraise_with_actionable_message``.\n \"\"\"\n # Point the OAuth credential path at a file we never create so\n # detection fails the same way ``/dev/null`` does at runtime.\n missing = tmp_path / \"missing.json\"\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"anthropic_oauth\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n # ``decepticon_core.utils.logging`` sets the parent decepticon logger\n # to ``propagate=False`` so library output doesn't double up in\n # apps that own their root handler. caplog hooks the root logger,\n # so without re-enabling propagation our ERROR record never\n # reaches pytest's capture buffer. Re-enable for the duration of\n # the call and let monkeypatch restore on teardown.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.ERROR):\n creds = _resolve_credentials()\n\n # Backward compat: still returns the all-API-methods fallback.\n assert creds.methods == Credentials.all_api_methods().methods\n\n # ERROR diagnostic must mention the env var name so an operator\n # scanning ``decepticon logs langgraph`` knows where to look.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert any(\"DECEPTICON_AUTH_PRIORITY\" in r.getMessage() for r in error_records), (\n f\"No ERROR log mentioned DECEPTICON_AUTH_PRIORITY. \"\n f\"Got: {[r.getMessage() for r in error_records]}\"\n )\n\n def test_implicit_priority_no_creds_stays_info_level(self, monkeypatch, caplog):\n \"\"\"Implicit priority (no DECEPTICON_AUTH_PRIORITY set) with no\n detectable credentials is the import-friendly CI/test path:\n we keep the existing INFO log and ``all_api_methods()`` return\n so module imports work without keys present. Only **explicit**\n priority with no matches gets escalated to ERROR.\n \"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n\n # Match the explicit-priority test's pattern so caplog sees the\n # records — the decepticon parent logger defaults to\n # ``propagate=False`` per ``decepticon_core.utils.logging``.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.DEBUG):\n creds = _resolve_credentials()\n\n assert creds.methods == Credentials.all_api_methods().methods\n # No ERROR records — this case stays informational.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert not error_records, (\n f\"Unexpected ERROR logs in implicit-priority path: \"\n f\"{[r.getMessage() for r in error_records]}\"\n )\n\n def test_ollama_local_only_returns_ollama_chain(self, monkeypatch):\n \"\"\"Issue #106: a user with only OLLAMA_API_BASE / OLLAMA_MODEL set\n (no API keys, no OAuth) must get a chain of one — Ollama only.\n Falling back to all-API-methods would produce 401 errors on every\n provider the user doesn't have.\"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL]\n\n def test_explicit_priority_with_ollama_local(self, monkeypatch):\n \"\"\"User opts into Ollama via explicit priority — the resolver\n recognizes it as configured when OLLAMA_API_BASE is set.\"\"\"\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"ollama_local,anthropic_api\")\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\n# ── Temperature handling (issue #107) ───────────────────────────────────\n\n\nclass TestTemperatureDrop:\n \"\"\"Claude Opus 4.7 rejects ``temperature`` regardless of route. The\n factory must drop it on every Opus 4 surface (anthropic/, auth/,\n openrouter/anthropic/) and keep it for everyone else.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _model_drops_temperature\n\n self._drops = _model_drops_temperature\n\n def test_anthropic_opus_drops_temperature(self):\n assert self._drops(\"anthropic/claude-opus-4-7\") is True\n\n def test_oauth_opus_drops_temperature(self):\n assert self._drops(\"auth/claude-opus-4-7\") is True\n\n def test_openrouter_opus_drops_temperature(self):\n assert self._drops(\"openrouter/anthropic/claude-opus-4-7\") is True\n\n def test_sonnet_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-sonnet-4-6\") is False\n\n def test_haiku_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-haiku-4-5\") is False\n\n def test_openai_keeps_temperature(self):\n assert self._drops(\"openai/gpt-5.5\") is False\n\n def test_ollama_keeps_temperature(self):\n assert self._drops(\"ollama_chat/qwen3-coder:30b\") is False\n\n\n# ── Actionable error translation (issue #107 + community feedback) ──────\n\n\nclass TestActionableErrorTranslation:\n \"\"\"The OSS user complaint: every upstream failure surfaces as 'An\n internal error occurred', stripping the message that would tell them\n what to fix. Each branch below verifies one class of error gets\n rewritten with a remediation hint and the model id that hit the\n failure.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _reraise_with_actionable_message\n\n self._translate = _reraise_with_actionable_message\n\n def test_no_fallback_model_group_branch(self):\n exc = Exception(\n \"litellm.BadRequestError: ... No fallback model group found \"\n \"for original model_group=anthropic/claude-opus-4-7.\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"no provider fallback\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_400_bad_request_branch(self):\n # openai.BadRequestError carries 'Error code: 400' in repr.\n exc = Exception(\"Error code: 400 - {'error': {'message': 'temperature is deprecated'}}\")\n type(exc).__name__ # noqa: B018 — sanity, ensures Exception default name\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n assert \"rejected the request (400)\" in str(info.value)\n\n def test_401_authentication_branch(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\"Error code: 401 - invalid_api_key\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"credentials (401)\" in msg\n assert \"decepticon onboard --reset\" in msg\n\n def test_429_ratelimit_branch(self):\n exc = type(\"RateLimitError\", (Exception,), {})(\"Error code: 429\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"rate limit (429)\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_404_notfound_with_ollama_hint(self):\n exc = type(\"NotFoundError\", (Exception,), {})(\"Error code: 404 - model not found\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"ollama_chat/nonexistent\")\n msg = str(info.value)\n assert \"404\" in msg\n assert \"OLLAMA_MODEL\" in msg\n\n def test_unmatched_error_passes_through(self):\n # Anything we don't recognize must NOT raise — the caller's\n # ``raise`` follows and re-raises the original exception with\n # full traceback.\n exc = ValueError(\"something completely unrelated\")\n # Should not raise from the helper.\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n\n def test_401_redacts_bearer_token_keeps_guidance(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\n \"Error code: 401 - {'error': 'invalid_api_key'} \"\n \"Authorization: Bearer sk-ant-SECRET123FAKEPLACEHOLDERTOKEN\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"sk-ant-SECRET123FAKEPLACEHOLDERTOKEN\" not in msg\n assert \"SECRET123FAKEPLACEHOLDERTOKEN\" not in msg\n assert \"[REDACTED]\" in msg\n assert \"credentials (401)\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n\n def test_400_redacts_api_key_kwarg_keeps_guidance(self):\n exc = Exception(\n \"Error code: 400 - bad request from provider with api_key=sk-SECRETfakeplaceholdervalue99\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"sk-SECRETfakeplaceholdervalue99\" not in msg\n assert \"SECRETfakeplaceholdervalue99\" not in msg\n assert \"[REDACTED]\" in msg\n assert \"rejected the request (400)\" in msg\n\n def test_redact_secrets_preserves_nonsecret_text(self):\n from decepticon.llm.factory import _redact_secrets\n\n text = \"No fallback model group found for model_group=anthropic/claude-opus-4-7.\"\n assert _redact_secrets(text) == text\n\n\n# ── DeepSeek V4 Pro reasoning_content passthrough ────────────────────────\n\n\nclass TestDeepSeekReasoningContent:\n \"\"\"Verify reasoning_content survives both the non-streaming and streaming\n paths so it can be sent back on subsequent API turns.\"\"\"\n\n def test_create_chat_result_extracts_reasoning_content(self):\n \"\"\"Non-streaming: _create_chat_result captures reasoning_content\n from the response dict's choice message.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Simulate the OpenAI response dict with reasoning_content\n response_dict = {\n \"choices\": [\n {\n \"message\": {\n \"role\": \"assistant\",\n \"content\": \"Four\",\n \"reasoning_content\": \"I think therefore I am\",\n },\n \"finish_reason\": \"stop\",\n }\n ],\n \"usage\": {},\n }\n\n # Mock the parent's _create_chat_result to return a ChatResult without reasoning_content\n msg = AIMessage(content=\"Four\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert (\n result.generations[0].message.additional_kwargs[\"reasoning_content\"]\n == \"I think therefore I am\"\n )\n\n def test_create_chat_result_skips_when_absent(self):\n \"\"\"Non-streaming: no crash when reasoning_content is missing.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n response_dict = {\n \"choices\": [\n {\"message\": {\"role\": \"assistant\", \"content\": \"hi\"}, \"finish_reason\": \"stop\"}\n ],\n \"usage\": {},\n }\n\n msg = AIMessage(content=\"hi\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert \"reasoning_content\" not in result.generations[0].message.additional_kwargs\n\n def test_convert_chunk_injects_reasoning_content(self):\n \"\"\"Streaming: _convert_chunk_to_generation_chunk captures\n reasoning_content from the raw delta and injects it into\n AIMessageChunk.additional_kwargs.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Build a fake raw SSE chunk dict with reasoning_content in the delta\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Let me think...\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n # The parent's _convert_chunk_to_generation_chunk builds the\n # ChatGenerationChunk but ignores reasoning_content. We mock it\n # to return a chunk without reasoning_content, then verify our\n # override injects it.\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n # Bind the real method to our mock instance\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert result.message.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n\n def test_convert_chunk_no_reasoning_leaves_kwargs_clean(self):\n \"\"\"Streaming: when delta has no reasoning_content, additional_kwargs\n is not polluted.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\"role\": \"assistant\", \"content\": \"hi\"},\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_reasoning_content_accumulates_across_chunks(self):\n \"\"\"Streaming: reasoning_content from multiple chunks concatenates\n via AIMessageChunk.__add__ (merge_dicts).\"\"\"\n from langchain_core.messages import AIMessageChunk\n\n c1 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"Let me \"})\n c2 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"think...\"})\n c3 = AIMessageChunk(content=\"Hello!\", additional_kwargs={})\n\n merged = c1 + c2 + c3\n assert merged.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n assert merged.content == \"Hello!\"\n\n def test_get_request_payload_injects_reasoning_content(self):\n \"\"\"Outbound: _get_request_payload injects reasoning_content from\n AIMessage.additional_kwargs into serialized assistant message dicts.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"hi\"),\n AIMessage(\n content=\"hello\",\n additional_kwargs={\"reasoning_content\": \"thinking...\"},\n ),\n ]\n\n # Mock super()._get_request_payload to return a payload without reasoning_content\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"hi\"},\n {\"role\": \"assistant\", \"content\": \"hello\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thinking...\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert payload[\"extra_body\"][\"thinking\"] == {\"type\": \"enabled\"}\n assert payload[\"reasoning_effort\"] == \"high\"\n\n def test_convert_chunk_empty_reasoning_content_ignored(self):\n \"\"\"Streaming: empty string reasoning_content in delta is ignored.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"hi\",\n \"reasoning_content\": \"\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n # Empty string is falsy — should not pollute additional_kwargs\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_none_delta_no_crash(self):\n \"\"\"Streaming: None delta in choices does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": None,\n \"finish_reason\": \"stop\",\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_empty_choices_no_crash(self):\n \"\"\"Streaming: empty choices array does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"choices\": []}\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_parent_returns_none(self):\n \"\"\"Streaming: when parent returns None, we return None too.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"type\": \"content.delta\"} # parent returns None for these\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=None,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is None\n\n def test_convert_chunk_beta_stream_format(self):\n \"\"\"Streaming: handles beta.chat.completions.stream nested chunk format.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Some LangChain versions nest under \"chunk\" key\n raw_chunk = {\n \"chunk\": {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Thinking via beta format...\",\n },\n \"finish_reason\": None,\n }\n ],\n },\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert (\n result.message.additional_kwargs[\"reasoning_content\"] == \"Thinking via beta format...\"\n )\n\n def test_get_request_payload_multiple_assistant_messages(self):\n \"\"\"Outbound: each assistant message gets its own reasoning_content.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"q1\"),\n AIMessage(content=\"a1\", additional_kwargs={\"reasoning_content\": \"thought1\"}),\n HumanMessage(content=\"q2\"),\n AIMessage(content=\"a2\", additional_kwargs={\"reasoning_content\": \"thought2\"}),\n ]\n\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"q1\"},\n {\"role\": \"assistant\", \"content\": \"a1\"},\n {\"role\": \"user\", \"content\": \"q2\"},\n {\"role\": \"assistant\", \"content\": \"a2\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thought1\"\n assert payload[\"messages\"][3][\"reasoning_content\"] == \"thought2\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert \"reasoning_content\" not in payload[\"messages\"][2]\n\n def test_model_detection(self):\n \"\"\"Factory routes DeepSeek V4 (pro + flash) and legacy reasoner.\n\n Per DeepSeek's API docs ``deepseek-reasoner`` is the deprecated\n alias for ``deepseek-v4-flash`` thinking mode, so v4-flash also\n returns ``reasoning_content`` and the API rejects subsequent\n tool turns when the field is omitted. Closes #201, #220.\n \"\"\"\n from decepticon.llm.factory import _model_is_deepseek_thinking\n\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-pro\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-flash\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-reasoner\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-chat\") is False\n assert _model_is_deepseek_thinking(\"openai/gpt-5.5\") is False\n\n\n# ── LLAMACPP_LOCAL credential detection (issue #151) ────────────────────\n\n\n_LLAMACPP_RELATED_ENV = (\n \"LLAMACPP_API_BASE\",\n \"LLAMACPP_MODEL\",\n \"LLAMACPP_API_KEY\",\n)\n\n\ndef _scrub_other_credentials(monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"Delete any env that would be detected as another credential.\n\n The credential-detection tests below need to assert \"only LLAMACPP\n is detected\"; without this scrub, a developer's exported\n ``ANTHROPIC_API_KEY`` would creep in and the assertion would fail\n locally only.\n \"\"\"\n for var in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"GROQ_API_KEY\",\n \"TOGETHER_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"COHERE_API_KEY\",\n \"MOONSHOT_API_KEY\",\n \"ZAI_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"GITHUB_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n ):\n monkeypatch.delenv(var, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n\nclass TestLlamacppLocalConfigured:\n \"\"\"``_llamacpp_local_configured`` is the gate for adding\n ``LLAMACPP_LOCAL`` to the credentials chain. Either env var being\n set is enough — neither requires the other, mirroring the\n LM Studio / Ollama detection contract.\n \"\"\"\n\n def test_returns_false_when_neither_env_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n assert _llamacpp_local_configured() is False\n\n def test_returns_true_when_only_base_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n assert _llamacpp_local_configured() is True\n\n def test_returns_true_when_only_model_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n assert _llamacpp_local_configured() is True\n\n def test_whitespace_does_not_count_as_set(self, monkeypatch):\n \"\"\"Stray ``LLAMACPP_API_BASE=`` lines in .env must not silently\n opt the user in — same fail-soft as the Ollama detector.\"\"\"\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \" \")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"\")\n assert _llamacpp_local_configured() is False\n\n\nclass TestResolveCredentialsForLlamacpp:\n \"\"\"End-to-end check that ``_resolve_credentials`` picks up the\n user's llama.cpp config — both the explicit-priority path and the\n \"only LLAMACPP_* detected\" auto-fallback at the bottom of\n ``_resolve_credentials``.\n \"\"\"\n\n def test_only_llamacpp_detected_returns_llamacpp_only_chain(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL]\n\n def test_priority_list_with_llamacpp_picks_it_up(self, monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"User wrote an explicit DECEPTICON_AUTH_PRIORITY listing\n llamacpp_local — the resolver must respect that ordering.\"\"\"\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen-coder\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"llamacpp_local,anthropic_api\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\nclass TestLLMTimeout:\n \"\"\"Whole-coroutine LLM request timeout (DECEPTICON_LLM_TIMEOUT_SECONDS).\"\"\"\n\n def test_call_with_timeout_raises_typed_exception(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> str:\n await asyncio.sleep(10)\n return \"done\"\n\n with pytest.raises(LLMTimeoutError, match=\"timed out after 0.01 seconds\"):\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n\n def test_call_with_timeout_preserves_original_timeout_as_cause(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> None:\n await asyncio.sleep(10)\n\n try:\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n except LLMTimeoutError as exc:\n assert isinstance(exc.__cause__, asyncio.TimeoutError)\n else:\n raise AssertionError(\"expected LLMTimeoutError\")\n\n def test_call_with_timeout_returns_successful_result(self) -> None:\n from decepticon.llm.factory import call_with_timeout\n\n async def fast_call() -> str:\n await asyncio.sleep(0)\n return \"ok\"\n\n assert asyncio.run(call_with_timeout(fast_call(), 1)) == \"ok\"\n\n def test_call_with_timeout_propagates_non_timeout_exceptions(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def failing_call() -> None:\n raise RuntimeError(\"upstream 500\")\n\n with pytest.raises(RuntimeError, match=\"upstream 500\"):\n asyncio.run(call_with_timeout(failing_call(), 1))\n # And not as a timeout error\n try:\n asyncio.run(call_with_timeout(failing_call(), 1))\n except LLMTimeoutError:\n raise AssertionError(\"non-timeout exception was retyped\")\n except RuntimeError:\n pass\n\n def test_env_override_takes_effect(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"3\")\n assert _resolve_llm_timeout_seconds() == 3.0\n\n def test_default_timeout_is_600_when_env_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.delenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", raising=False)\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_blank_env_falls_back_to_default(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \" \")\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_invalid_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"not-a-number\")\n with pytest.raises(ValueError):\n _resolve_llm_timeout_seconds()\n\n def test_non_positive_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"0\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"-5\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n"}, "0f9b1a13d523a60561f829d7f81337946577c6af": {"t": 1785755399.5752954, "n": 55177, "h": "5b6e6deb32c7020e", "c": "\"\"\"Unit tests for decepticon.llm.factory.\"\"\"\n\nimport asyncio\nimport logging\n\nimport pytest\n\nfrom decepticon.llm.factory import (\n LLMFactory,\n _is_real_key,\n _llamacpp_local_configured,\n _method_is_configured,\n _oauth_credentials_present,\n _oauth_env_credentials_present,\n _resolve_credentials,\n)\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\n\n\nclass TestIsRealKey:\n \"\"\"Vendor-aware API key validation.\n\n The launcher writes ``your-…-key-here`` placeholders into .env so the\n user can later swap in a real key. The factory needs to reject those\n plus any obvious junk (short strings, ``placeholder``/``not-used``\n markers, vendor-prefix mismatches) so the credentials inventory\n reflects what actually works at request time.\n \"\"\"\n\n def test_rejects_empty_and_placeholder_template(self) -> None:\n assert _is_real_key(\"\") is False\n assert _is_real_key(\" \") is False\n assert _is_real_key(\"your-anthropic-key-here\") is False\n assert _is_real_key(\"YOUR-OPENAI-KEY-HERE\") is False # case-insensitive\n\n def test_rejects_short_strings(self) -> None:\n # Under 24 chars — every vendor-issued key exceeds this.\n assert _is_real_key(\"sk-tooshort\") is False\n\n def test_rejects_placeholder_tokens_in_value(self) -> None:\n long_enough = \"x\" * 30\n for token in (\"placeholder\", \"not-used\", \"dummy\", \"fake\", \"example\"):\n assert _is_real_key(f\"sk-{token}-{long_enough}\") is False, token\n\n def test_accepts_realistic_keys_without_method(self) -> None:\n # Without method context, prefix check is skipped.\n assert _is_real_key(\"sk-ant-api03-realtokenfortestingauthrouting12345\") is True\n assert _is_real_key(\"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\") is True\n\n def test_rejects_wrong_vendor_prefix(self) -> None:\n # An OpenAI-shaped key in the Anthropic slot must be caught.\n openai_key = \"sk-proj-realopenaitokenfortestingauthrouting12345\"\n assert _is_real_key(openai_key, AuthMethod.ANTHROPIC_API) is False\n # …and vice versa.\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.GOOGLE_API) is False\n\n def test_accepts_correct_vendor_prefix(self) -> None:\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.ANTHROPIC_API) is True\n google_key = \"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\"\n assert _is_real_key(google_key, AuthMethod.GOOGLE_API) is True\n\n\nclass TestOAuthCredentialsPresent:\n \"\"\"OAuth detection requires the credential file alongside the boolean.\n\n Without the file check, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place OAuth in\n every fallback chain and generate one 401 per request.\n \"\"\"\n\n def test_returns_false_when_file_missing(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(tmp_path / \"absent.json\"))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_when_file_is_empty(self, monkeypatch, tmp_path) -> None:\n # ``/dev/null``-style mounts read as empty — must fail closed.\n empty = tmp_path / \"empty.json\"\n empty.write_text(\"\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(empty))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_on_invalid_json(self, monkeypatch, tmp_path) -> None:\n bad = tmp_path / \"bad.json\"\n bad.write_text(\"{not-json\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(bad))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_true_when_file_is_well_formed(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"credentials.json\"\n good.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeef\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is True\n\n def test_codex_path_via_env_override(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"auth.json\"\n good.write_text('{\"tokens\": {\"access_token\": \"ABC\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CODEX_AUTH_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.OPENAI_OAUTH) is True\n\n\nclass TestOAuthEnvCredentials:\n \"\"\"B12: env-backed OAuth creds count as configured, not just on-disk files.\n Before the fix, ``_method_is_configured`` required a credential *file* even\n though the LiteLLM handlers also accept env-var tokens (GEMINI_ACCESS_TOKEN,\n GROK_SESSION_TOKEN, COPILOT_REFRESH_TOKEN, …). Env-only setups were silently\n dropped from the fallback chain.\n \"\"\"\n\n def test_env_present_returns_true_for_each_method(self, monkeypatch) -> None:\n cases = {\n AuthMethod.GOOGLE_OAUTH: \"GEMINI_ACCESS_TOKEN\",\n AuthMethod.COPILOT_OAUTH: \"COPILOT_REFRESH_TOKEN\",\n AuthMethod.GROK_OAUTH: \"GROK_SESSION_TOKEN\",\n AuthMethod.PERPLEXITY_OAUTH: \"PERPLEXITY_SESSION_TOKEN\",\n AuthMethod.ANTHROPIC_OAUTH: \"ANTHROPIC_OAUTH_TOKEN\",\n }\n for method, env_var in cases.items():\n monkeypatch.setenv(env_var, \"tok-from-env\")\n assert _oauth_env_credentials_present(method) is True\n monkeypatch.delenv(env_var, raising=False)\n\n def test_env_only_method_is_configured_without_file(self, monkeypatch, tmp_path) -> None:\n # Flag true + env token, but the credential file is absent.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.setenv(\"DECEPTICON_AUTH_GEMINI\", \"true\")\n monkeypatch.setenv(\"GEMINI_ACCESS_TOKEN\", \"ya29.env-token\")\n assert _oauth_credentials_present(AuthMethod.GOOGLE_OAUTH) is False\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is True\n\n def test_method_not_configured_without_file_or_env(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.setenv(\"DECEPTICON_AUTH_GEMINI\", \"true\")\n monkeypatch.delenv(\"GEMINI_ACCESS_TOKEN\", raising=False)\n monkeypatch.delenv(\"GEMINI_SESSION_COOKIES\", raising=False)\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is False\n\n def test_env_creds_still_require_intent_flag(self, monkeypatch, tmp_path) -> None:\n # Env token present but the boolean intent flag is off → not configured.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"GEMINI_TOKENS_PATH\", str(tmp_path / \"absent.json\"))\n monkeypatch.delenv(\"DECEPTICON_AUTH_GEMINI\", raising=False)\n monkeypatch.setenv(\"GEMINI_ACCESS_TOKEN\", \"ya29.env-token\")\n assert _method_is_configured(AuthMethod.GOOGLE_OAUTH) is False\n\n def test_codex_has_no_env_token_override(self, monkeypatch) -> None:\n # Codex authenticates only from its file; no env-token override exists.\n monkeypatch.setenv(\"CODEX_ACCESS_TOKEN\", \"should-not-count\")\n assert _oauth_env_credentials_present(AuthMethod.OPENAI_OAUTH) is False\n\n\nclass TestLLMFactory:\n def setup_method(self):\n self.proxy = ProxyConfig(url=\"http://localhost:4000\", api_key=\"test-key\")\n # Build an explicit mapping so the test doesn't depend on env vars.\n creds = Credentials.all_api_methods()\n self.mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n self.factory = LLMFactory(self.proxy, self.mapping)\n\n def test_factory_initializes(self):\n assert self.factory.proxy_url == \"http://localhost:4000\"\n\n def test_get_model_returns_chat_model(self):\n model = self.factory.get_model(\"recon\")\n assert model is not None\n assert model.model_name == \"anthropic/claude-haiku-4-5\"\n\n def test_get_model_caches_instances(self):\n m1 = self.factory.get_model(\"recon\")\n m2 = self.factory.get_model(\"recon\")\n assert m1 is m2\n\n def test_get_model_different_roles_different_models(self):\n recon = self.factory.get_model(\"recon\")\n decepticon = self.factory.get_model(\"decepticon\")\n assert recon is not decepticon\n assert recon.model_name != decepticon.model_name\n\n def test_get_model_unknown_role_raises(self):\n with pytest.raises(KeyError, match=\"No model assignment\"):\n self.factory.get_model(\"nonexistent\")\n\n def test_router_accessible(self):\n assert self.factory.router is not None\n\n def test_get_fallback_models_full_chain(self):\n models = self.factory.get_fallback_models(\"recon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5-nano\",\n \"gemini/gemini-2.5-flash-lite\",\n \"deepseek/deepseek-v4-flash\",\n \"openrouter/anthropic/claude-haiku-4-5\",\n \"nvidia_nim/meta/llama-3.2-3b-instruct\",\n ]\n\n def test_get_fallback_models_high_tier_includes_all_methods(self):\n models = self.factory.get_fallback_models(\"decepticon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5.5\",\n \"gemini/gemini-2.5-pro\",\n \"minimax/MiniMax-M2.5\",\n \"deepseek/deepseek-v4-pro\",\n \"xai/grok-4.3\",\n \"mistral/mistral-large-latest\",\n \"openrouter/anthropic/claude-opus-4-7\",\n \"nvidia_nim/meta/llama-3.3-70b-instruct\",\n ]\n\n def test_get_fallback_models_without_fallback(self):\n # Single-credential mapping → no fallback.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n factory = LLMFactory(self.proxy, mapping)\n assert factory.get_fallback_models(\"recon\") == []\n\n def test_explicit_credentials_param(self):\n # Constructor accepts a Credentials object instead of a full mapping.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n factory = LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n assert factory.get_model(\"decepticon\").model_name == \"openai/gpt-5.5\"\n\n\nclass TestLLMFactoryHealthCheck:\n def test_health_check_returns_false_when_no_proxy(self):\n proxy = ProxyConfig(url=\"http://localhost:19999\")\n factory = LLMFactory(proxy, mapping=LLMModelMapping())\n assert asyncio.run(factory.health_check()) is False\n\n\nclass TestResolveCredentials:\n def test_real_keys_only(self, monkeypatch):\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"your-openai-key-here\") # placeholder\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_API]\n\n def test_oauth_only(self, monkeypatch, tmp_path):\n # OAuth detection requires the credential FILE alongside the\n # boolean — point Claude Code at a temp credentials file so the\n # test runs deterministically regardless of host state.\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_OAUTH]\n\n def test_oauth_flag_without_credential_file_is_dropped(self, monkeypatch, tmp_path):\n \"\"\"Stale ``DECEPTICON_AUTH_CLAUDE_CODE=true`` after ``codex logout``\n (or after the user deleted ``~/.claude/.credentials.json``) must\n not place the OAuth method into the chain — otherwise every\n request 401s before falling back to the next provider.\n \"\"\"\n # Point both the primary and legacy fallback paths at tmp_path so\n # any ``~/.claude/.credentials.json`` or\n # ``~/.config/anthropic/q/tokens.json`` on the dev host doesn't\n # accidentally satisfy the file-presence check.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n missing = tmp_path / \"missing.json\" # never created\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n creds = _resolve_credentials()\n assert AuthMethod.ANTHROPIC_OAUTH not in creds.methods\n\n def test_oauth_plus_api_priority_default(self, monkeypatch, tmp_path):\n # Default priority is anthropic_oauth > anthropic_api > openai_api ...\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n ]\n\n def test_explicit_priority_override(self, monkeypatch):\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"openai_api,anthropic_api\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n monkeypatch.delenv(\"GEMINI_API_KEY\", raising=False)\n monkeypatch.delenv(\"MINIMAX_API_KEY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OPENAI_API, AuthMethod.ANTHROPIC_API]\n\n def test_placeholder_falls_back_to_all_api_methods(self, monkeypatch):\n \"\"\"When every detected method is a placeholder/missing, the resolver\n falls back to the all-API-methods inventory so module-level agent\n constructors stay importable in CI / dev shells without keys.\"\"\"\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"your-anthropic-key-here\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n ]\n\n def test_explicit_priority_no_creds_logs_error(self, monkeypatch, tmp_path, caplog):\n \"\"\"An explicit ``DECEPTICON_AUTH_PRIORITY`` whose every listed\n method fails detection (typical of a broken OAuth credentials\n mount — e.g. ``CLAUDE_CREDENTIALS_VOLUME`` unset and the\n compose file bound ``/dev/null`` into the container) must\n surface the root cause at ERROR level.\n\n Otherwise the silent fallback to ``all_api_methods()`` runs\n through nine unrelated providers and the operator only sees a\n downstream 401-cascade — confusingly masked as a 429 once the\n routed-to provider (e.g. NVIDIA NIM) cools down. Backward\n compat is preserved: the resolver still returns\n ``all_api_methods()`` so module imports remain green; the\n real model call surfaces a separate, actionable error via\n ``_reraise_with_actionable_message``.\n \"\"\"\n # Point the OAuth credential path at a file we never create so\n # detection fails the same way ``/dev/null`` does at runtime.\n missing = tmp_path / \"missing.json\"\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"anthropic_oauth\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n # ``decepticon_core.utils.logging`` sets the parent decepticon logger\n # to ``propagate=False`` so library output doesn't double up in\n # apps that own their root handler. caplog hooks the root logger,\n # so without re-enabling propagation our ERROR record never\n # reaches pytest's capture buffer. Re-enable for the duration of\n # the call and let monkeypatch restore on teardown.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.ERROR):\n creds = _resolve_credentials()\n\n # Backward compat: still returns the all-API-methods fallback.\n assert creds.methods == Credentials.all_api_methods().methods\n\n # ERROR diagnostic must mention the env var name so an operator\n # scanning ``decepticon logs langgraph`` knows where to look.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert any(\"DECEPTICON_AUTH_PRIORITY\" in r.getMessage() for r in error_records), (\n f\"No ERROR log mentioned DECEPTICON_AUTH_PRIORITY. \"\n f\"Got: {[r.getMessage() for r in error_records]}\"\n )\n\n def test_implicit_priority_no_creds_stays_info_level(self, monkeypatch, caplog):\n \"\"\"Implicit priority (no DECEPTICON_AUTH_PRIORITY set) with no\n detectable credentials is the import-friendly CI/test path:\n we keep the existing INFO log and ``all_api_methods()`` return\n so module imports work without keys present. Only **explicit**\n priority with no matches gets escalated to ERROR.\n \"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n\n # Match the explicit-priority test's pattern so caplog sees the\n # records — the decepticon parent logger defaults to\n # ``propagate=False`` per ``decepticon_core.utils.logging``.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.DEBUG):\n creds = _resolve_credentials()\n\n assert creds.methods == Credentials.all_api_methods().methods\n # No ERROR records — this case stays informational.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert not error_records, (\n f\"Unexpected ERROR logs in implicit-priority path: \"\n f\"{[r.getMessage() for r in error_records]}\"\n )\n\n def test_ollama_local_only_returns_ollama_chain(self, monkeypatch):\n \"\"\"Issue #106: a user with only OLLAMA_API_BASE / OLLAMA_MODEL set\n (no API keys, no OAuth) must get a chain of one — Ollama only.\n Falling back to all-API-methods would produce 401 errors on every\n provider the user doesn't have.\"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL]\n\n def test_explicit_priority_with_ollama_local(self, monkeypatch):\n \"\"\"User opts into Ollama via explicit priority — the resolver\n recognizes it as configured when OLLAMA_API_BASE is set.\"\"\"\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"ollama_local,anthropic_api\")\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\n# ── Temperature handling (issue #107) ───────────────────────────────────\n\n\nclass TestTemperatureDrop:\n \"\"\"Claude Opus 4.7 rejects ``temperature`` regardless of route. The\n factory must drop it on every Opus 4 surface (anthropic/, auth/,\n openrouter/anthropic/) and keep it for everyone else.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _model_drops_temperature\n\n self._drops = _model_drops_temperature\n\n def test_anthropic_opus_drops_temperature(self):\n assert self._drops(\"anthropic/claude-opus-4-7\") is True\n\n def test_oauth_opus_drops_temperature(self):\n assert self._drops(\"auth/claude-opus-4-7\") is True\n\n def test_openrouter_opus_drops_temperature(self):\n assert self._drops(\"openrouter/anthropic/claude-opus-4-7\") is True\n\n def test_sonnet_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-sonnet-4-6\") is False\n\n def test_haiku_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-haiku-4-5\") is False\n\n def test_openai_keeps_temperature(self):\n assert self._drops(\"openai/gpt-5.5\") is False\n\n def test_ollama_keeps_temperature(self):\n assert self._drops(\"ollama_chat/qwen3-coder:30b\") is False\n\n\n# ── Actionable error translation (issue #107 + community feedback) ──────\n\n\nclass TestActionableErrorTranslation:\n \"\"\"The OSS user complaint: every upstream failure surfaces as 'An\n internal error occurred', stripping the message that would tell them\n what to fix. Each branch below verifies one class of error gets\n rewritten with a remediation hint and the model id that hit the\n failure.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _reraise_with_actionable_message\n\n self._translate = _reraise_with_actionable_message\n\n def test_no_fallback_model_group_branch(self):\n exc = Exception(\n \"litellm.BadRequestError: ... No fallback model group found \"\n \"for original model_group=anthropic/claude-opus-4-7.\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"no provider fallback\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_400_bad_request_branch(self):\n # openai.BadRequestError carries 'Error code: 400' in repr.\n exc = Exception(\"Error code: 400 - {'error': {'message': 'temperature is deprecated'}}\")\n type(exc).__name__ # noqa: B018 — sanity, ensures Exception default name\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n assert \"rejected the request (400)\" in str(info.value)\n\n def test_401_authentication_branch(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\"Error code: 401 - invalid_api_key\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"credentials (401)\" in msg\n assert \"decepticon onboard --reset\" in msg\n\n def test_429_ratelimit_branch(self):\n exc = type(\"RateLimitError\", (Exception,), {})(\"Error code: 429\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"rate limit (429)\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_404_notfound_with_ollama_hint(self):\n exc = type(\"NotFoundError\", (Exception,), {})(\"Error code: 404 - model not found\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"ollama_chat/nonexistent\")\n msg = str(info.value)\n assert \"404\" in msg\n assert \"OLLAMA_MODEL\" in msg\n\n def test_unmatched_error_passes_through(self):\n # Anything we don't recognize must NOT raise — the caller's\n # ``raise`` follows and re-raises the original exception with\n # full traceback.\n exc = ValueError(\"something completely unrelated\")\n # Should not raise from the helper.\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n\n\n# ── DeepSeek V4 Pro reasoning_content passthrough ────────────────────────\n\n\nclass TestDeepSeekReasoningContent:\n \"\"\"Verify reasoning_content survives both the non-streaming and streaming\n paths so it can be sent back on subsequent API turns.\"\"\"\n\n def test_create_chat_result_extracts_reasoning_content(self):\n \"\"\"Non-streaming: _create_chat_result captures reasoning_content\n from the response dict's choice message.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Simulate the OpenAI response dict with reasoning_content\n response_dict = {\n \"choices\": [\n {\n \"message\": {\n \"role\": \"assistant\",\n \"content\": \"Four\",\n \"reasoning_content\": \"I think therefore I am\",\n },\n \"finish_reason\": \"stop\",\n }\n ],\n \"usage\": {},\n }\n\n # Mock the parent's _create_chat_result to return a ChatResult without reasoning_content\n msg = AIMessage(content=\"Four\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert (\n result.generations[0].message.additional_kwargs[\"reasoning_content\"]\n == \"I think therefore I am\"\n )\n\n def test_create_chat_result_skips_when_absent(self):\n \"\"\"Non-streaming: no crash when reasoning_content is missing.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n response_dict = {\n \"choices\": [\n {\"message\": {\"role\": \"assistant\", \"content\": \"hi\"}, \"finish_reason\": \"stop\"}\n ],\n \"usage\": {},\n }\n\n msg = AIMessage(content=\"hi\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert \"reasoning_content\" not in result.generations[0].message.additional_kwargs\n\n def test_convert_chunk_injects_reasoning_content(self):\n \"\"\"Streaming: _convert_chunk_to_generation_chunk captures\n reasoning_content from the raw delta and injects it into\n AIMessageChunk.additional_kwargs.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Build a fake raw SSE chunk dict with reasoning_content in the delta\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Let me think...\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n # The parent's _convert_chunk_to_generation_chunk builds the\n # ChatGenerationChunk but ignores reasoning_content. We mock it\n # to return a chunk without reasoning_content, then verify our\n # override injects it.\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n # Bind the real method to our mock instance\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert result.message.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n\n def test_convert_chunk_no_reasoning_leaves_kwargs_clean(self):\n \"\"\"Streaming: when delta has no reasoning_content, additional_kwargs\n is not polluted.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\"role\": \"assistant\", \"content\": \"hi\"},\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_reasoning_content_accumulates_across_chunks(self):\n \"\"\"Streaming: reasoning_content from multiple chunks concatenates\n via AIMessageChunk.__add__ (merge_dicts).\"\"\"\n from langchain_core.messages import AIMessageChunk\n\n c1 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"Let me \"})\n c2 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"think...\"})\n c3 = AIMessageChunk(content=\"Hello!\", additional_kwargs={})\n\n merged = c1 + c2 + c3\n assert merged.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n assert merged.content == \"Hello!\"\n\n def test_get_request_payload_injects_reasoning_content(self):\n \"\"\"Outbound: _get_request_payload injects reasoning_content from\n AIMessage.additional_kwargs into serialized assistant message dicts.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"hi\"),\n AIMessage(\n content=\"hello\",\n additional_kwargs={\"reasoning_content\": \"thinking...\"},\n ),\n ]\n\n # Mock super()._get_request_payload to return a payload without reasoning_content\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"hi\"},\n {\"role\": \"assistant\", \"content\": \"hello\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thinking...\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert payload[\"extra_body\"][\"thinking\"] == {\"type\": \"enabled\"}\n assert payload[\"reasoning_effort\"] == \"high\"\n\n def test_convert_chunk_empty_reasoning_content_ignored(self):\n \"\"\"Streaming: empty string reasoning_content in delta is ignored.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"hi\",\n \"reasoning_content\": \"\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n # Empty string is falsy — should not pollute additional_kwargs\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_none_delta_no_crash(self):\n \"\"\"Streaming: None delta in choices does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": None,\n \"finish_reason\": \"stop\",\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_empty_choices_no_crash(self):\n \"\"\"Streaming: empty choices array does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"choices\": []}\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_parent_returns_none(self):\n \"\"\"Streaming: when parent returns None, we return None too.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"type\": \"content.delta\"} # parent returns None for these\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=None,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is None\n\n def test_convert_chunk_beta_stream_format(self):\n \"\"\"Streaming: handles beta.chat.completions.stream nested chunk format.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Some LangChain versions nest under \"chunk\" key\n raw_chunk = {\n \"chunk\": {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Thinking via beta format...\",\n },\n \"finish_reason\": None,\n }\n ],\n },\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert (\n result.message.additional_kwargs[\"reasoning_content\"] == \"Thinking via beta format...\"\n )\n\n def test_get_request_payload_multiple_assistant_messages(self):\n \"\"\"Outbound: each assistant message gets its own reasoning_content.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"q1\"),\n AIMessage(content=\"a1\", additional_kwargs={\"reasoning_content\": \"thought1\"}),\n HumanMessage(content=\"q2\"),\n AIMessage(content=\"a2\", additional_kwargs={\"reasoning_content\": \"thought2\"}),\n ]\n\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"q1\"},\n {\"role\": \"assistant\", \"content\": \"a1\"},\n {\"role\": \"user\", \"content\": \"q2\"},\n {\"role\": \"assistant\", \"content\": \"a2\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thought1\"\n assert payload[\"messages\"][3][\"reasoning_content\"] == \"thought2\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert \"reasoning_content\" not in payload[\"messages\"][2]\n\n def test_model_detection(self):\n \"\"\"Factory routes DeepSeek V4 (pro + flash) and legacy reasoner.\n\n Per DeepSeek's API docs ``deepseek-reasoner`` is the deprecated\n alias for ``deepseek-v4-flash`` thinking mode, so v4-flash also\n returns ``reasoning_content`` and the API rejects subsequent\n tool turns when the field is omitted. Closes #201, #220.\n \"\"\"\n from decepticon.llm.factory import _model_is_deepseek_thinking\n\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-pro\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-flash\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-reasoner\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-chat\") is False\n assert _model_is_deepseek_thinking(\"openai/gpt-5.5\") is False\n\n\n# ── LLAMACPP_LOCAL credential detection (issue #151) ────────────────────\n\n\n_LLAMACPP_RELATED_ENV = (\n \"LLAMACPP_API_BASE\",\n \"LLAMACPP_MODEL\",\n \"LLAMACPP_API_KEY\",\n)\n\n\ndef _scrub_other_credentials(monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"Delete any env that would be detected as another credential.\n\n The credential-detection tests below need to assert \"only LLAMACPP\n is detected\"; without this scrub, a developer's exported\n ``ANTHROPIC_API_KEY`` would creep in and the assertion would fail\n locally only.\n \"\"\"\n for var in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"GROQ_API_KEY\",\n \"TOGETHER_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"COHERE_API_KEY\",\n \"MOONSHOT_API_KEY\",\n \"ZAI_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"GITHUB_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n ):\n monkeypatch.delenv(var, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n\nclass TestLlamacppLocalConfigured:\n \"\"\"``_llamacpp_local_configured`` is the gate for adding\n ``LLAMACPP_LOCAL`` to the credentials chain. Either env var being\n set is enough — neither requires the other, mirroring the\n LM Studio / Ollama detection contract.\n \"\"\"\n\n def test_returns_false_when_neither_env_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n assert _llamacpp_local_configured() is False\n\n def test_returns_true_when_only_base_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n assert _llamacpp_local_configured() is True\n\n def test_returns_true_when_only_model_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n assert _llamacpp_local_configured() is True\n\n def test_whitespace_does_not_count_as_set(self, monkeypatch):\n \"\"\"Stray ``LLAMACPP_API_BASE=`` lines in .env must not silently\n opt the user in — same fail-soft as the Ollama detector.\"\"\"\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \" \")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"\")\n assert _llamacpp_local_configured() is False\n\n\nclass TestResolveCredentialsForLlamacpp:\n \"\"\"End-to-end check that ``_resolve_credentials`` picks up the\n user's llama.cpp config — both the explicit-priority path and the\n \"only LLAMACPP_* detected\" auto-fallback at the bottom of\n ``_resolve_credentials``.\n \"\"\"\n\n def test_only_llamacpp_detected_returns_llamacpp_only_chain(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL]\n\n def test_priority_list_with_llamacpp_picks_it_up(self, monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"User wrote an explicit DECEPTICON_AUTH_PRIORITY listing\n llamacpp_local — the resolver must respect that ordering.\"\"\"\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen-coder\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"llamacpp_local,anthropic_api\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\nclass TestLLMTimeout:\n \"\"\"Whole-coroutine LLM request timeout (DECEPTICON_LLM_TIMEOUT_SECONDS).\"\"\"\n\n def test_call_with_timeout_raises_typed_exception(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> str:\n await asyncio.sleep(10)\n return \"done\"\n\n with pytest.raises(LLMTimeoutError, match=\"timed out after 0.01 seconds\"):\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n\n def test_call_with_timeout_preserves_original_timeout_as_cause(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> None:\n await asyncio.sleep(10)\n\n try:\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n except LLMTimeoutError as exc:\n assert isinstance(exc.__cause__, asyncio.TimeoutError)\n else:\n raise AssertionError(\"expected LLMTimeoutError\")\n\n def test_call_with_timeout_returns_successful_result(self) -> None:\n from decepticon.llm.factory import call_with_timeout\n\n async def fast_call() -> str:\n await asyncio.sleep(0)\n return \"ok\"\n\n assert asyncio.run(call_with_timeout(fast_call(), 1)) == \"ok\"\n\n def test_call_with_timeout_propagates_non_timeout_exceptions(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def failing_call() -> None:\n raise RuntimeError(\"upstream 500\")\n\n with pytest.raises(RuntimeError, match=\"upstream 500\"):\n asyncio.run(call_with_timeout(failing_call(), 1))\n # And not as a timeout error\n try:\n asyncio.run(call_with_timeout(failing_call(), 1))\n except LLMTimeoutError:\n raise AssertionError(\"non-timeout exception was retyped\")\n except RuntimeError:\n pass\n\n def test_env_override_takes_effect(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"3\")\n assert _resolve_llm_timeout_seconds() == 3.0\n\n def test_default_timeout_is_600_when_env_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.delenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", raising=False)\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_blank_env_falls_back_to_default(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \" \")\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_invalid_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"not-a-number\")\n with pytest.raises(ValueError):\n _resolve_llm_timeout_seconds()\n\n def test_non_positive_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"0\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"-5\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n"}, "31e796bb04c0b5395ea353930ccced57e1262d23": {"t": 1785755401.7757592, "n": 52200, "h": "f0c83353f9d80e79", "c": "\"\"\"Unit tests for decepticon.llm.factory.\"\"\"\n\nimport asyncio\nimport logging\n\nimport pytest\n\nfrom decepticon.llm.factory import (\n LLMFactory,\n _is_real_key,\n _llamacpp_local_configured,\n _oauth_credentials_present,\n _resolve_credentials,\n)\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\n\n\nclass TestIsRealKey:\n \"\"\"Vendor-aware API key validation.\n\n The launcher writes ``your-…-key-here`` placeholders into .env so the\n user can later swap in a real key. The factory needs to reject those\n plus any obvious junk (short strings, ``placeholder``/``not-used``\n markers, vendor-prefix mismatches) so the credentials inventory\n reflects what actually works at request time.\n \"\"\"\n\n def test_rejects_empty_and_placeholder_template(self) -> None:\n assert _is_real_key(\"\") is False\n assert _is_real_key(\" \") is False\n assert _is_real_key(\"your-anthropic-key-here\") is False\n assert _is_real_key(\"YOUR-OPENAI-KEY-HERE\") is False # case-insensitive\n\n def test_rejects_short_strings(self) -> None:\n # Under 24 chars — every vendor-issued key exceeds this.\n assert _is_real_key(\"sk-tooshort\") is False\n\n def test_rejects_placeholder_tokens_in_value(self) -> None:\n long_enough = \"x\" * 30\n for token in (\"placeholder\", \"not-used\", \"dummy\", \"fake\", \"example\"):\n assert _is_real_key(f\"sk-{token}-{long_enough}\") is False, token\n\n def test_accepts_realistic_keys_without_method(self) -> None:\n # Without method context, prefix check is skipped.\n assert _is_real_key(\"sk-ant-api03-realtokenfortestingauthrouting12345\") is True\n assert _is_real_key(\"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\") is True\n\n def test_rejects_wrong_vendor_prefix(self) -> None:\n # An OpenAI-shaped key in the Anthropic slot must be caught.\n openai_key = \"sk-proj-realopenaitokenfortestingauthrouting12345\"\n assert _is_real_key(openai_key, AuthMethod.ANTHROPIC_API) is False\n # …and vice versa.\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.GOOGLE_API) is False\n\n def test_accepts_correct_vendor_prefix(self) -> None:\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.ANTHROPIC_API) is True\n google_key = \"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\"\n assert _is_real_key(google_key, AuthMethod.GOOGLE_API) is True\n\n\nclass TestOAuthCredentialsPresent:\n \"\"\"OAuth detection requires the credential file alongside the boolean.\n\n Without the file check, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place OAuth in\n every fallback chain and generate one 401 per request.\n \"\"\"\n\n def test_returns_false_when_file_missing(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(tmp_path / \"absent.json\"))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_when_file_is_empty(self, monkeypatch, tmp_path) -> None:\n # ``/dev/null``-style mounts read as empty — must fail closed.\n empty = tmp_path / \"empty.json\"\n empty.write_text(\"\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(empty))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_on_invalid_json(self, monkeypatch, tmp_path) -> None:\n bad = tmp_path / \"bad.json\"\n bad.write_text(\"{not-json\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(bad))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_true_when_file_is_well_formed(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"credentials.json\"\n good.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeef\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is True\n\n def test_codex_path_via_env_override(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"auth.json\"\n good.write_text('{\"tokens\": {\"access_token\": \"ABC\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CODEX_AUTH_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.OPENAI_OAUTH) is True\n\n\nclass TestLLMFactory:\n def setup_method(self):\n self.proxy = ProxyConfig(url=\"http://localhost:4000\", api_key=\"test-key\")\n # Build an explicit mapping so the test doesn't depend on env vars.\n creds = Credentials.all_api_methods()\n self.mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n self.factory = LLMFactory(self.proxy, self.mapping)\n\n def test_factory_initializes(self):\n assert self.factory.proxy_url == \"http://localhost:4000\"\n\n def test_get_model_returns_chat_model(self):\n model = self.factory.get_model(\"recon\")\n assert model is not None\n assert model.model_name == \"anthropic/claude-haiku-4-5\"\n\n def test_get_model_caches_instances(self):\n m1 = self.factory.get_model(\"recon\")\n m2 = self.factory.get_model(\"recon\")\n assert m1 is m2\n\n def test_get_model_different_roles_different_models(self):\n recon = self.factory.get_model(\"recon\")\n decepticon = self.factory.get_model(\"decepticon\")\n assert recon is not decepticon\n assert recon.model_name != decepticon.model_name\n\n def test_get_model_unknown_role_raises(self):\n with pytest.raises(KeyError, match=\"No model assignment\"):\n self.factory.get_model(\"nonexistent\")\n\n def test_router_accessible(self):\n assert self.factory.router is not None\n\n def test_get_fallback_models_full_chain(self):\n models = self.factory.get_fallback_models(\"recon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5-nano\",\n \"gemini/gemini-2.5-flash-lite\",\n \"deepseek/deepseek-v4-flash\",\n \"openrouter/anthropic/claude-haiku-4-5\",\n \"nvidia_nim/meta/llama-3.2-3b-instruct\",\n ]\n\n def test_get_fallback_models_high_tier_includes_all_methods(self):\n models = self.factory.get_fallback_models(\"decepticon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5.5\",\n \"gemini/gemini-2.5-pro\",\n \"minimax/MiniMax-M2.5\",\n \"deepseek/deepseek-v4-pro\",\n \"xai/grok-4.3\",\n \"mistral/mistral-large-latest\",\n \"openrouter/anthropic/claude-opus-4-7\",\n \"nvidia_nim/meta/llama-3.3-70b-instruct\",\n ]\n\n def test_get_fallback_models_without_fallback(self):\n # Single-credential mapping → no fallback.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n factory = LLMFactory(self.proxy, mapping)\n assert factory.get_fallback_models(\"recon\") == []\n\n def test_explicit_credentials_param(self):\n # Constructor accepts a Credentials object instead of a full mapping.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n factory = LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n assert factory.get_model(\"decepticon\").model_name == \"openai/gpt-5.5\"\n\n\nclass TestLLMFactoryHealthCheck:\n def test_health_check_returns_false_when_no_proxy(self):\n proxy = ProxyConfig(url=\"http://localhost:19999\")\n factory = LLMFactory(proxy, mapping=LLMModelMapping())\n assert asyncio.run(factory.health_check()) is False\n\n\nclass TestResolveCredentials:\n def test_real_keys_only(self, monkeypatch):\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"your-openai-key-here\") # placeholder\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_API]\n\n def test_oauth_only(self, monkeypatch, tmp_path):\n # OAuth detection requires the credential FILE alongside the\n # boolean — point Claude Code at a temp credentials file so the\n # test runs deterministically regardless of host state.\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_OAUTH]\n\n def test_oauth_flag_without_credential_file_is_dropped(self, monkeypatch, tmp_path):\n \"\"\"Stale ``DECEPTICON_AUTH_CLAUDE_CODE=true`` after ``codex logout``\n (or after the user deleted ``~/.claude/.credentials.json``) must\n not place the OAuth method into the chain — otherwise every\n request 401s before falling back to the next provider.\n \"\"\"\n # Point both the primary and legacy fallback paths at tmp_path so\n # any ``~/.claude/.credentials.json`` or\n # ``~/.config/anthropic/q/tokens.json`` on the dev host doesn't\n # accidentally satisfy the file-presence check.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n missing = tmp_path / \"missing.json\" # never created\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n creds = _resolve_credentials()\n assert AuthMethod.ANTHROPIC_OAUTH not in creds.methods\n\n def test_oauth_plus_api_priority_default(self, monkeypatch, tmp_path):\n # Default priority is anthropic_oauth > anthropic_api > openai_api ...\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n ]\n\n def test_explicit_priority_override(self, monkeypatch):\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"openai_api,anthropic_api\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n monkeypatch.delenv(\"GEMINI_API_KEY\", raising=False)\n monkeypatch.delenv(\"MINIMAX_API_KEY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OPENAI_API, AuthMethod.ANTHROPIC_API]\n\n def test_placeholder_falls_back_to_all_api_methods(self, monkeypatch):\n \"\"\"When every detected method is a placeholder/missing, the resolver\n falls back to the all-API-methods inventory so module-level agent\n constructors stay importable in CI / dev shells without keys.\"\"\"\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"your-anthropic-key-here\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n ]\n\n def test_explicit_priority_no_creds_logs_error(self, monkeypatch, tmp_path, caplog):\n \"\"\"An explicit ``DECEPTICON_AUTH_PRIORITY`` whose every listed\n method fails detection (typical of a broken OAuth credentials\n mount — e.g. ``CLAUDE_CREDENTIALS_VOLUME`` unset and the\n compose file bound ``/dev/null`` into the container) must\n surface the root cause at ERROR level.\n\n Otherwise the silent fallback to ``all_api_methods()`` runs\n through nine unrelated providers and the operator only sees a\n downstream 401-cascade — confusingly masked as a 429 once the\n routed-to provider (e.g. NVIDIA NIM) cools down. Backward\n compat is preserved: the resolver still returns\n ``all_api_methods()`` so module imports remain green; the\n real model call surfaces a separate, actionable error via\n ``_reraise_with_actionable_message``.\n \"\"\"\n # Point the OAuth credential path at a file we never create so\n # detection fails the same way ``/dev/null`` does at runtime.\n missing = tmp_path / \"missing.json\"\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"anthropic_oauth\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n # ``decepticon_core.utils.logging`` sets the parent decepticon logger\n # to ``propagate=False`` so library output doesn't double up in\n # apps that own their root handler. caplog hooks the root logger,\n # so without re-enabling propagation our ERROR record never\n # reaches pytest's capture buffer. Re-enable for the duration of\n # the call and let monkeypatch restore on teardown.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.ERROR):\n creds = _resolve_credentials()\n\n # Backward compat: still returns the all-API-methods fallback.\n assert creds.methods == Credentials.all_api_methods().methods\n\n # ERROR diagnostic must mention the env var name so an operator\n # scanning ``decepticon logs langgraph`` knows where to look.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert any(\"DECEPTICON_AUTH_PRIORITY\" in r.getMessage() for r in error_records), (\n f\"No ERROR log mentioned DECEPTICON_AUTH_PRIORITY. \"\n f\"Got: {[r.getMessage() for r in error_records]}\"\n )\n\n def test_implicit_priority_no_creds_stays_info_level(self, monkeypatch, caplog):\n \"\"\"Implicit priority (no DECEPTICON_AUTH_PRIORITY set) with no\n detectable credentials is the import-friendly CI/test path:\n we keep the existing INFO log and ``all_api_methods()`` return\n so module imports work without keys present. Only **explicit**\n priority with no matches gets escalated to ERROR.\n \"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n\n # Match the explicit-priority test's pattern so caplog sees the\n # records — the decepticon parent logger defaults to\n # ``propagate=False`` per ``decepticon_core.utils.logging``.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.DEBUG):\n creds = _resolve_credentials()\n\n assert creds.methods == Credentials.all_api_methods().methods\n # No ERROR records — this case stays informational.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert not error_records, (\n f\"Unexpected ERROR logs in implicit-priority path: \"\n f\"{[r.getMessage() for r in error_records]}\"\n )\n\n def test_ollama_local_only_returns_ollama_chain(self, monkeypatch):\n \"\"\"Issue #106: a user with only OLLAMA_API_BASE / OLLAMA_MODEL set\n (no API keys, no OAuth) must get a chain of one — Ollama only.\n Falling back to all-API-methods would produce 401 errors on every\n provider the user doesn't have.\"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL]\n\n def test_explicit_priority_with_ollama_local(self, monkeypatch):\n \"\"\"User opts into Ollama via explicit priority — the resolver\n recognizes it as configured when OLLAMA_API_BASE is set.\"\"\"\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"ollama_local,anthropic_api\")\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\n# ── Temperature handling (issue #107) ───────────────────────────────────\n\n\nclass TestTemperatureDrop:\n \"\"\"Claude Opus 4.7 rejects ``temperature`` regardless of route. The\n factory must drop it on every Opus 4 surface (anthropic/, auth/,\n openrouter/anthropic/) and keep it for everyone else.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _model_drops_temperature\n\n self._drops = _model_drops_temperature\n\n def test_anthropic_opus_drops_temperature(self):\n assert self._drops(\"anthropic/claude-opus-4-7\") is True\n\n def test_oauth_opus_drops_temperature(self):\n assert self._drops(\"auth/claude-opus-4-7\") is True\n\n def test_openrouter_opus_drops_temperature(self):\n assert self._drops(\"openrouter/anthropic/claude-opus-4-7\") is True\n\n def test_sonnet_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-sonnet-4-6\") is False\n\n def test_haiku_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-haiku-4-5\") is False\n\n def test_openai_keeps_temperature(self):\n assert self._drops(\"openai/gpt-5.5\") is False\n\n def test_ollama_keeps_temperature(self):\n assert self._drops(\"ollama_chat/qwen3-coder:30b\") is False\n\n\n# ── Actionable error translation (issue #107 + community feedback) ──────\n\n\nclass TestActionableErrorTranslation:\n \"\"\"The OSS user complaint: every upstream failure surfaces as 'An\n internal error occurred', stripping the message that would tell them\n what to fix. Each branch below verifies one class of error gets\n rewritten with a remediation hint and the model id that hit the\n failure.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _reraise_with_actionable_message\n\n self._translate = _reraise_with_actionable_message\n\n def test_no_fallback_model_group_branch(self):\n exc = Exception(\n \"litellm.BadRequestError: ... No fallback model group found \"\n \"for original model_group=anthropic/claude-opus-4-7.\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"no provider fallback\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_400_bad_request_branch(self):\n # openai.BadRequestError carries 'Error code: 400' in repr.\n exc = Exception(\"Error code: 400 - {'error': {'message': 'temperature is deprecated'}}\")\n type(exc).__name__ # noqa: B018 — sanity, ensures Exception default name\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n assert \"rejected the request (400)\" in str(info.value)\n\n def test_401_authentication_branch(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\"Error code: 401 - invalid_api_key\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"credentials (401)\" in msg\n assert \"decepticon onboard --reset\" in msg\n\n def test_429_ratelimit_branch(self):\n exc = type(\"RateLimitError\", (Exception,), {})(\"Error code: 429\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"rate limit (429)\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_404_notfound_with_ollama_hint(self):\n exc = type(\"NotFoundError\", (Exception,), {})(\"Error code: 404 - model not found\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"ollama_chat/nonexistent\")\n msg = str(info.value)\n assert \"404\" in msg\n assert \"OLLAMA_MODEL\" in msg\n\n def test_unmatched_error_passes_through(self):\n # Anything we don't recognize must NOT raise — the caller's\n # ``raise`` follows and re-raises the original exception with\n # full traceback.\n exc = ValueError(\"something completely unrelated\")\n # Should not raise from the helper.\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n\n\n# ── DeepSeek V4 Pro reasoning_content passthrough ────────────────────────\n\n\nclass TestDeepSeekReasoningContent:\n \"\"\"Verify reasoning_content survives both the non-streaming and streaming\n paths so it can be sent back on subsequent API turns.\"\"\"\n\n def test_create_chat_result_extracts_reasoning_content(self):\n \"\"\"Non-streaming: _create_chat_result captures reasoning_content\n from the response dict's choice message.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Simulate the OpenAI response dict with reasoning_content\n response_dict = {\n \"choices\": [\n {\n \"message\": {\n \"role\": \"assistant\",\n \"content\": \"Four\",\n \"reasoning_content\": \"I think therefore I am\",\n },\n \"finish_reason\": \"stop\",\n }\n ],\n \"usage\": {},\n }\n\n # Mock the parent's _create_chat_result to return a ChatResult without reasoning_content\n msg = AIMessage(content=\"Four\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert (\n result.generations[0].message.additional_kwargs[\"reasoning_content\"]\n == \"I think therefore I am\"\n )\n\n def test_create_chat_result_skips_when_absent(self):\n \"\"\"Non-streaming: no crash when reasoning_content is missing.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n response_dict = {\n \"choices\": [\n {\"message\": {\"role\": \"assistant\", \"content\": \"hi\"}, \"finish_reason\": \"stop\"}\n ],\n \"usage\": {},\n }\n\n msg = AIMessage(content=\"hi\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert \"reasoning_content\" not in result.generations[0].message.additional_kwargs\n\n def test_convert_chunk_injects_reasoning_content(self):\n \"\"\"Streaming: _convert_chunk_to_generation_chunk captures\n reasoning_content from the raw delta and injects it into\n AIMessageChunk.additional_kwargs.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Build a fake raw SSE chunk dict with reasoning_content in the delta\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Let me think...\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n # The parent's _convert_chunk_to_generation_chunk builds the\n # ChatGenerationChunk but ignores reasoning_content. We mock it\n # to return a chunk without reasoning_content, then verify our\n # override injects it.\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n # Bind the real method to our mock instance\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert result.message.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n\n def test_convert_chunk_no_reasoning_leaves_kwargs_clean(self):\n \"\"\"Streaming: when delta has no reasoning_content, additional_kwargs\n is not polluted.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\"role\": \"assistant\", \"content\": \"hi\"},\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_reasoning_content_accumulates_across_chunks(self):\n \"\"\"Streaming: reasoning_content from multiple chunks concatenates\n via AIMessageChunk.__add__ (merge_dicts).\"\"\"\n from langchain_core.messages import AIMessageChunk\n\n c1 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"Let me \"})\n c2 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"think...\"})\n c3 = AIMessageChunk(content=\"Hello!\", additional_kwargs={})\n\n merged = c1 + c2 + c3\n assert merged.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n assert merged.content == \"Hello!\"\n\n def test_get_request_payload_injects_reasoning_content(self):\n \"\"\"Outbound: _get_request_payload injects reasoning_content from\n AIMessage.additional_kwargs into serialized assistant message dicts.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"hi\"),\n AIMessage(\n content=\"hello\",\n additional_kwargs={\"reasoning_content\": \"thinking...\"},\n ),\n ]\n\n # Mock super()._get_request_payload to return a payload without reasoning_content\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"hi\"},\n {\"role\": \"assistant\", \"content\": \"hello\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thinking...\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert payload[\"extra_body\"][\"thinking\"] == {\"type\": \"enabled\"}\n assert payload[\"reasoning_effort\"] == \"high\"\n\n def test_convert_chunk_empty_reasoning_content_ignored(self):\n \"\"\"Streaming: empty string reasoning_content in delta is ignored.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"hi\",\n \"reasoning_content\": \"\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n # Empty string is falsy — should not pollute additional_kwargs\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_none_delta_no_crash(self):\n \"\"\"Streaming: None delta in choices does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": None,\n \"finish_reason\": \"stop\",\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_empty_choices_no_crash(self):\n \"\"\"Streaming: empty choices array does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"choices\": []}\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_parent_returns_none(self):\n \"\"\"Streaming: when parent returns None, we return None too.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"type\": \"content.delta\"} # parent returns None for these\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=None,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is None\n\n def test_convert_chunk_beta_stream_format(self):\n \"\"\"Streaming: handles beta.chat.completions.stream nested chunk format.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Some LangChain versions nest under \"chunk\" key\n raw_chunk = {\n \"chunk\": {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Thinking via beta format...\",\n },\n \"finish_reason\": None,\n }\n ],\n },\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert (\n result.message.additional_kwargs[\"reasoning_content\"] == \"Thinking via beta format...\"\n )\n\n def test_get_request_payload_multiple_assistant_messages(self):\n \"\"\"Outbound: each assistant message gets its own reasoning_content.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"q1\"),\n AIMessage(content=\"a1\", additional_kwargs={\"reasoning_content\": \"thought1\"}),\n HumanMessage(content=\"q2\"),\n AIMessage(content=\"a2\", additional_kwargs={\"reasoning_content\": \"thought2\"}),\n ]\n\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"q1\"},\n {\"role\": \"assistant\", \"content\": \"a1\"},\n {\"role\": \"user\", \"content\": \"q2\"},\n {\"role\": \"assistant\", \"content\": \"a2\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thought1\"\n assert payload[\"messages\"][3][\"reasoning_content\"] == \"thought2\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert \"reasoning_content\" not in payload[\"messages\"][2]\n\n def test_model_detection(self):\n \"\"\"Factory routes DeepSeek V4 (pro + flash) and legacy reasoner.\n\n Per DeepSeek's API docs ``deepseek-reasoner`` is the deprecated\n alias for ``deepseek-v4-flash`` thinking mode, so v4-flash also\n returns ``reasoning_content`` and the API rejects subsequent\n tool turns when the field is omitted. Closes #201, #220.\n \"\"\"\n from decepticon.llm.factory import _model_is_deepseek_thinking\n\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-pro\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-flash\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-reasoner\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-chat\") is False\n assert _model_is_deepseek_thinking(\"openai/gpt-5.5\") is False\n\n\n# ── LLAMACPP_LOCAL credential detection (issue #151) ────────────────────\n\n\n_LLAMACPP_RELATED_ENV = (\n \"LLAMACPP_API_BASE\",\n \"LLAMACPP_MODEL\",\n \"LLAMACPP_API_KEY\",\n)\n\n\ndef _scrub_other_credentials(monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"Delete any env that would be detected as another credential.\n\n The credential-detection tests below need to assert \"only LLAMACPP\n is detected\"; without this scrub, a developer's exported\n ``ANTHROPIC_API_KEY`` would creep in and the assertion would fail\n locally only.\n \"\"\"\n for var in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"GROQ_API_KEY\",\n \"TOGETHER_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"COHERE_API_KEY\",\n \"MOONSHOT_API_KEY\",\n \"ZAI_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"GITHUB_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n ):\n monkeypatch.delenv(var, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n\nclass TestLlamacppLocalConfigured:\n \"\"\"``_llamacpp_local_configured`` is the gate for adding\n ``LLAMACPP_LOCAL`` to the credentials chain. Either env var being\n set is enough — neither requires the other, mirroring the\n LM Studio / Ollama detection contract.\n \"\"\"\n\n def test_returns_false_when_neither_env_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n assert _llamacpp_local_configured() is False\n\n def test_returns_true_when_only_base_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n assert _llamacpp_local_configured() is True\n\n def test_returns_true_when_only_model_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n assert _llamacpp_local_configured() is True\n\n def test_whitespace_does_not_count_as_set(self, monkeypatch):\n \"\"\"Stray ``LLAMACPP_API_BASE=`` lines in .env must not silently\n opt the user in — same fail-soft as the Ollama detector.\"\"\"\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \" \")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"\")\n assert _llamacpp_local_configured() is False\n\n\nclass TestResolveCredentialsForLlamacpp:\n \"\"\"End-to-end check that ``_resolve_credentials`` picks up the\n user's llama.cpp config — both the explicit-priority path and the\n \"only LLAMACPP_* detected\" auto-fallback at the bottom of\n ``_resolve_credentials``.\n \"\"\"\n\n def test_only_llamacpp_detected_returns_llamacpp_only_chain(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL]\n\n def test_priority_list_with_llamacpp_picks_it_up(self, monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"User wrote an explicit DECEPTICON_AUTH_PRIORITY listing\n llamacpp_local — the resolver must respect that ordering.\"\"\"\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen-coder\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"llamacpp_local,anthropic_api\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\nclass TestLLMTimeout:\n \"\"\"Whole-coroutine LLM request timeout (DECEPTICON_LLM_TIMEOUT_SECONDS).\"\"\"\n\n def test_call_with_timeout_raises_typed_exception(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> str:\n await asyncio.sleep(10)\n return \"done\"\n\n with pytest.raises(LLMTimeoutError, match=\"timed out after 0.01 seconds\"):\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n\n def test_call_with_timeout_preserves_original_timeout_as_cause(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> None:\n await asyncio.sleep(10)\n\n try:\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n except LLMTimeoutError as exc:\n assert isinstance(exc.__cause__, asyncio.TimeoutError)\n else:\n raise AssertionError(\"expected LLMTimeoutError\")\n\n def test_call_with_timeout_returns_successful_result(self) -> None:\n from decepticon.llm.factory import call_with_timeout\n\n async def fast_call() -> str:\n await asyncio.sleep(0)\n return \"ok\"\n\n assert asyncio.run(call_with_timeout(fast_call(), 1)) == \"ok\"\n\n def test_call_with_timeout_propagates_non_timeout_exceptions(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def failing_call() -> None:\n raise RuntimeError(\"upstream 500\")\n\n with pytest.raises(RuntimeError, match=\"upstream 500\"):\n asyncio.run(call_with_timeout(failing_call(), 1))\n # And not as a timeout error\n try:\n asyncio.run(call_with_timeout(failing_call(), 1))\n except LLMTimeoutError:\n raise AssertionError(\"non-timeout exception was retyped\")\n except RuntimeError:\n pass\n\n def test_env_override_takes_effect(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"3\")\n assert _resolve_llm_timeout_seconds() == 3.0\n\n def test_default_timeout_is_600_when_env_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.delenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", raising=False)\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_blank_env_falls_back_to_default(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \" \")\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_invalid_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"not-a-number\")\n with pytest.raises(ValueError):\n _resolve_llm_timeout_seconds()\n\n def test_non_positive_env_raises_value_error(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"0\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"-5\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n"}, "eb456230f6bccb2300e14aa2097b2bc9c022ba71": {"t": 1785755403.7168505, "n": 52256, "h": "d6b0fe49efc3e621", "c": "\"\"\"Unit tests for decepticon.llm.factory.\"\"\"\n\nimport asyncio\nimport logging\n\nimport pytest\n\nfrom decepticon.llm.factory import (\n LLMFactory,\n _is_real_key,\n _llamacpp_local_configured,\n _oauth_credentials_present,\n _resolve_credentials,\n)\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\n\n\nclass TestIsRealKey:\n \"\"\"Vendor-aware API key validation.\n\n The launcher writes ``your-…-key-here`` placeholders into .env so the\n user can later swap in a real key. The factory needs to reject those\n plus any obvious junk (short strings, ``placeholder``/``not-used``\n markers, vendor-prefix mismatches) so the credentials inventory\n reflects what actually works at request time.\n \"\"\"\n\n def test_rejects_empty_and_placeholder_template(self) -> None:\n assert _is_real_key(\"\") is False\n assert _is_real_key(\" \") is False\n assert _is_real_key(\"your-anthropic-key-here\") is False\n assert _is_real_key(\"YOUR-OPENAI-KEY-HERE\") is False # case-insensitive\n\n def test_rejects_short_strings(self) -> None:\n # Under 24 chars — every vendor-issued key exceeds this.\n assert _is_real_key(\"sk-tooshort\") is False\n\n def test_rejects_placeholder_tokens_in_value(self) -> None:\n long_enough = \"x\" * 30\n for token in (\"placeholder\", \"not-used\", \"dummy\", \"fake\", \"example\"):\n assert _is_real_key(f\"sk-{token}-{long_enough}\") is False, token\n\n def test_accepts_realistic_keys_without_method(self) -> None:\n # Without method context, prefix check is skipped.\n assert _is_real_key(\"sk-ant-api03-realtokenfortestingauthrouting12345\") is True\n assert _is_real_key(\"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\") is True\n\n def test_rejects_wrong_vendor_prefix(self) -> None:\n # An OpenAI-shaped key in the Anthropic slot must be caught.\n openai_key = \"sk-proj-realopenaitokenfortestingauthrouting12345\"\n assert _is_real_key(openai_key, AuthMethod.ANTHROPIC_API) is False\n # …and vice versa.\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.GOOGLE_API) is False\n\n def test_accepts_correct_vendor_prefix(self) -> None:\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.ANTHROPIC_API) is True\n google_key = \"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\"\n assert _is_real_key(google_key, AuthMethod.GOOGLE_API) is True\n\n\nclass TestOAuthCredentialsPresent:\n \"\"\"OAuth detection requires the credential file alongside the boolean.\n\n Without the file check, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place OAuth in\n every fallback chain and generate one 401 per request.\n \"\"\"\n\n def test_returns_false_when_file_missing(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(tmp_path / \"absent.json\"))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_when_file_is_empty(self, monkeypatch, tmp_path) -> None:\n # ``/dev/null``-style mounts read as empty — must fail closed.\n empty = tmp_path / \"empty.json\"\n empty.write_text(\"\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(empty))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_on_invalid_json(self, monkeypatch, tmp_path) -> None:\n bad = tmp_path / \"bad.json\"\n bad.write_text(\"{not-json\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(bad))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_true_when_file_is_well_formed(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"credentials.json\"\n good.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeef\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is True\n\n def test_codex_path_via_env_override(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"auth.json\"\n good.write_text('{\"tokens\": {\"access_token\": \"ABC\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CODEX_AUTH_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.OPENAI_OAUTH) is True\n\n\nclass TestLLMFactory:\n def setup_method(self):\n self.proxy = ProxyConfig(url=\"http://localhost:4000\", api_key=\"test-key\")\n # Build an explicit mapping so the test doesn't depend on env vars.\n creds = Credentials.all_api_methods()\n self.mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n self.factory = LLMFactory(self.proxy, self.mapping)\n\n def test_factory_initializes(self):\n assert self.factory.proxy_url == \"http://localhost:4000\"\n\n def test_get_model_returns_chat_model(self):\n model = self.factory.get_model(\"recon\")\n assert model is not None\n assert model.model_name == \"anthropic/claude-haiku-4-5\"\n\n def test_get_model_caches_instances(self):\n m1 = self.factory.get_model(\"recon\")\n m2 = self.factory.get_model(\"recon\")\n assert m1 is m2\n\n def test_get_model_different_roles_different_models(self):\n recon = self.factory.get_model(\"recon\")\n decepticon = self.factory.get_model(\"decepticon\")\n assert recon is not decepticon\n assert recon.model_name != decepticon.model_name\n\n def test_get_model_unknown_role_raises(self):\n with pytest.raises(KeyError, match=\"No model assignment\"):\n self.factory.get_model(\"nonexistent\")\n\n def test_router_accessible(self):\n assert self.factory.router is not None\n\n def test_get_fallback_models_full_chain(self):\n models = self.factory.get_fallback_models(\"recon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5-nano\",\n \"gemini/gemini-2.5-flash-lite\",\n \"deepseek/deepseek-v4-flash\",\n \"openrouter/anthropic/claude-haiku-4-5\",\n \"nvidia_nim/meta/llama-3.2-3b-instruct\",\n ]\n\n def test_get_fallback_models_high_tier_includes_all_methods(self):\n models = self.factory.get_fallback_models(\"decepticon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5.5\",\n \"gemini/gemini-2.5-pro\",\n \"minimax/MiniMax-M2.5\",\n \"deepseek/deepseek-v4-pro\",\n \"xai/grok-4.3\",\n \"mistral/mistral-large-latest\",\n \"openrouter/anthropic/claude-opus-4-7\",\n \"nvidia_nim/meta/llama-3.3-70b-instruct\",\n ]\n\n def test_get_fallback_models_without_fallback(self):\n # Single-credential mapping → no fallback.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n factory = LLMFactory(self.proxy, mapping)\n assert factory.get_fallback_models(\"recon\") == []\n\n def test_explicit_credentials_param(self):\n # Constructor accepts a Credentials object instead of a full mapping.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n factory = LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n assert factory.get_model(\"decepticon\").model_name == \"openai/gpt-5.5\"\n\n\nclass TestLLMFactoryHealthCheck:\n def test_health_check_returns_false_when_no_proxy(self):\n proxy = ProxyConfig(url=\"http://localhost:19999\")\n factory = LLMFactory(proxy, mapping=LLMModelMapping())\n assert asyncio.run(factory.health_check()) is False\n\n\nclass TestResolveCredentials:\n def test_real_keys_only(self, monkeypatch):\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"your-openai-key-here\") # placeholder\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_API]\n\n def test_oauth_only(self, monkeypatch, tmp_path):\n # OAuth detection requires the credential FILE alongside the\n # boolean — point Claude Code at a temp credentials file so the\n # test runs deterministically regardless of host state.\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_OAUTH]\n\n def test_oauth_flag_without_credential_file_is_dropped(self, monkeypatch, tmp_path):\n \"\"\"Stale ``DECEPTICON_AUTH_CLAUDE_CODE=true`` after ``codex logout``\n (or after the user deleted ``~/.claude/.credentials.json``) must\n not place the OAuth method into the chain — otherwise every\n request 401s before falling back to the next provider.\n \"\"\"\n # Point both the primary and legacy fallback paths at tmp_path so\n # any ``~/.claude/.credentials.json`` or\n # ``~/.config/anthropic/q/tokens.json`` on the dev host doesn't\n # accidentally satisfy the file-presence check.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n missing = tmp_path / \"missing.json\" # never created\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n creds = _resolve_credentials()\n assert AuthMethod.ANTHROPIC_OAUTH not in creds.methods\n\n def test_oauth_plus_api_priority_default(self, monkeypatch, tmp_path):\n # Default priority is anthropic_oauth > anthropic_api > openai_api ...\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n ]\n\n def test_explicit_priority_override(self, monkeypatch):\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"openai_api,anthropic_api\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n monkeypatch.delenv(\"GEMINI_API_KEY\", raising=False)\n monkeypatch.delenv(\"MINIMAX_API_KEY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OPENAI_API, AuthMethod.ANTHROPIC_API]\n\n def test_placeholder_falls_back_to_all_api_methods(self, monkeypatch):\n \"\"\"When every detected method is a placeholder/missing, the resolver\n falls back to the all-API-methods inventory so module-level agent\n constructors stay importable in CI / dev shells without keys.\"\"\"\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"your-anthropic-key-here\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n ]\n\n def test_explicit_priority_no_creds_logs_error(self, monkeypatch, tmp_path, caplog):\n \"\"\"An explicit ``DECEPTICON_AUTH_PRIORITY`` whose every listed\n method fails detection (typical of a broken OAuth credentials\n mount — e.g. ``CLAUDE_CREDENTIALS_VOLUME`` unset and the\n compose file bound ``/dev/null`` into the container) must\n surface the root cause at ERROR level.\n\n Otherwise the silent fallback to ``all_api_methods()`` runs\n through nine unrelated providers and the operator only sees a\n downstream 401-cascade — confusingly masked as a 429 once the\n routed-to provider (e.g. NVIDIA NIM) cools down. Backward\n compat is preserved: the resolver still returns\n ``all_api_methods()`` so module imports remain green; the\n real model call surfaces a separate, actionable error via\n ``_reraise_with_actionable_message``.\n \"\"\"\n # Point the OAuth credential path at a file we never create so\n # detection fails the same way ``/dev/null`` does at runtime.\n missing = tmp_path / \"missing.json\"\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"anthropic_oauth\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n # ``decepticon_core.utils.logging`` sets the parent decepticon logger\n # to ``propagate=False`` so library output doesn't double up in\n # apps that own their root handler. caplog hooks the root logger,\n # so without re-enabling propagation our ERROR record never\n # reaches pytest's capture buffer. Re-enable for the duration of\n # the call and let monkeypatch restore on teardown.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.ERROR):\n creds = _resolve_credentials()\n\n # Backward compat: still returns the all-API-methods fallback.\n assert creds.methods == Credentials.all_api_methods().methods\n\n # ERROR diagnostic must mention the env var name so an operator\n # scanning ``decepticon logs langgraph`` knows where to look.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert any(\"DECEPTICON_AUTH_PRIORITY\" in r.getMessage() for r in error_records), (\n f\"No ERROR log mentioned DECEPTICON_AUTH_PRIORITY. \"\n f\"Got: {[r.getMessage() for r in error_records]}\"\n )\n\n def test_implicit_priority_no_creds_stays_info_level(self, monkeypatch, caplog):\n \"\"\"Implicit priority (no DECEPTICON_AUTH_PRIORITY set) with no\n detectable credentials is the import-friendly CI/test path:\n we keep the existing INFO log and ``all_api_methods()`` return\n so module imports work without keys present. Only **explicit**\n priority with no matches gets escalated to ERROR.\n \"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n\n # Match the explicit-priority test's pattern so caplog sees the\n # records — the decepticon parent logger defaults to\n # ``propagate=False`` per ``decepticon_core.utils.logging``.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.DEBUG):\n creds = _resolve_credentials()\n\n assert creds.methods == Credentials.all_api_methods().methods\n # No ERROR records — this case stays informational.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert not error_records, (\n f\"Unexpected ERROR logs in implicit-priority path: \"\n f\"{[r.getMessage() for r in error_records]}\"\n )\n\n def test_ollama_local_only_returns_ollama_chain(self, monkeypatch):\n \"\"\"Issue #106: a user with only OLLAMA_API_BASE / OLLAMA_MODEL set\n (no API keys, no OAuth) must get a chain of one — Ollama only.\n Falling back to all-API-methods would produce 401 errors on every\n provider the user doesn't have.\"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL]\n\n def test_explicit_priority_with_ollama_local(self, monkeypatch):\n \"\"\"User opts into Ollama via explicit priority — the resolver\n recognizes it as configured when OLLAMA_API_BASE is set.\"\"\"\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"ollama_local,anthropic_api\")\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\n# ── Temperature handling (issue #107) ───────────────────────────────────\n\n\nclass TestTemperatureDrop:\n \"\"\"Claude Opus 4.7 rejects ``temperature`` regardless of route. The\n factory must drop it on every Opus 4 surface (anthropic/, auth/,\n openrouter/anthropic/) and keep it for everyone else.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _model_drops_temperature\n\n self._drops = _model_drops_temperature\n\n def test_anthropic_opus_drops_temperature(self):\n assert self._drops(\"anthropic/claude-opus-4-7\") is True\n\n def test_oauth_opus_drops_temperature(self):\n assert self._drops(\"auth/claude-opus-4-7\") is True\n\n def test_openrouter_opus_drops_temperature(self):\n assert self._drops(\"openrouter/anthropic/claude-opus-4-7\") is True\n\n def test_sonnet_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-sonnet-4-6\") is False\n\n def test_haiku_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-haiku-4-5\") is False\n\n def test_openai_keeps_temperature(self):\n assert self._drops(\"openai/gpt-5.5\") is False\n\n def test_ollama_keeps_temperature(self):\n assert self._drops(\"ollama_chat/qwen3-coder:30b\") is False\n\n\n# ── Actionable error translation (issue #107 + community feedback) ──────\n\n\nclass TestActionableErrorTranslation:\n \"\"\"The OSS user complaint: every upstream failure surfaces as 'An\n internal error occurred', stripping the message that would tell them\n what to fix. Each branch below verifies one class of error gets\n rewritten with a remediation hint and the model id that hit the\n failure.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _reraise_with_actionable_message\n\n self._translate = _reraise_with_actionable_message\n\n def test_no_fallback_model_group_branch(self):\n exc = Exception(\n \"litellm.BadRequestError: ... No fallback model group found \"\n \"for original model_group=anthropic/claude-opus-4-7.\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"no provider fallback\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_400_bad_request_branch(self):\n # openai.BadRequestError carries 'Error code: 400' in repr.\n exc = Exception(\"Error code: 400 - {'error': {'message': 'temperature is deprecated'}}\")\n type(exc).__name__ # noqa: B018 — sanity, ensures Exception default name\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n assert \"rejected the request (400)\" in str(info.value)\n\n def test_401_authentication_branch(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\"Error code: 401 - invalid_api_key\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"credentials (401)\" in msg\n assert \"decepticon onboard --reset\" in msg\n\n def test_429_ratelimit_branch(self):\n exc = type(\"RateLimitError\", (Exception,), {})(\"Error code: 429\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"rate limit (429)\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_404_notfound_with_ollama_hint(self):\n exc = type(\"NotFoundError\", (Exception,), {})(\"Error code: 404 - model not found\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"ollama_chat/nonexistent\")\n msg = str(info.value)\n assert \"404\" in msg\n assert \"OLLAMA_MODEL\" in msg\n\n def test_unmatched_error_passes_through(self):\n # Anything we don't recognize must NOT raise — the caller's\n # ``raise`` follows and re-raises the original exception with\n # full traceback.\n exc = ValueError(\"something completely unrelated\")\n # Should not raise from the helper.\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n\n\n# ── DeepSeek V4 Pro reasoning_content passthrough ────────────────────────\n\n\nclass TestDeepSeekReasoningContent:\n \"\"\"Verify reasoning_content survives both the non-streaming and streaming\n paths so it can be sent back on subsequent API turns.\"\"\"\n\n def test_create_chat_result_extracts_reasoning_content(self):\n \"\"\"Non-streaming: _create_chat_result captures reasoning_content\n from the response dict's choice message.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Simulate the OpenAI response dict with reasoning_content\n response_dict = {\n \"choices\": [\n {\n \"message\": {\n \"role\": \"assistant\",\n \"content\": \"Four\",\n \"reasoning_content\": \"I think therefore I am\",\n },\n \"finish_reason\": \"stop\",\n }\n ],\n \"usage\": {},\n }\n\n # Mock the parent's _create_chat_result to return a ChatResult without reasoning_content\n msg = AIMessage(content=\"Four\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert (\n result.generations[0].message.additional_kwargs[\"reasoning_content\"]\n == \"I think therefore I am\"\n )\n\n def test_create_chat_result_skips_when_absent(self):\n \"\"\"Non-streaming: no crash when reasoning_content is missing.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n response_dict = {\n \"choices\": [\n {\"message\": {\"role\": \"assistant\", \"content\": \"hi\"}, \"finish_reason\": \"stop\"}\n ],\n \"usage\": {},\n }\n\n msg = AIMessage(content=\"hi\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert \"reasoning_content\" not in result.generations[0].message.additional_kwargs\n\n def test_convert_chunk_injects_reasoning_content(self):\n \"\"\"Streaming: _convert_chunk_to_generation_chunk captures\n reasoning_content from the raw delta and injects it into\n AIMessageChunk.additional_kwargs.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Build a fake raw SSE chunk dict with reasoning_content in the delta\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Let me think...\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n # The parent's _convert_chunk_to_generation_chunk builds the\n # ChatGenerationChunk but ignores reasoning_content. We mock it\n # to return a chunk without reasoning_content, then verify our\n # override injects it.\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n # Bind the real method to our mock instance\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert result.message.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n\n def test_convert_chunk_no_reasoning_leaves_kwargs_clean(self):\n \"\"\"Streaming: when delta has no reasoning_content, additional_kwargs\n is not polluted.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\"role\": \"assistant\", \"content\": \"hi\"},\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_reasoning_content_accumulates_across_chunks(self):\n \"\"\"Streaming: reasoning_content from multiple chunks concatenates\n via AIMessageChunk.__add__ (merge_dicts).\"\"\"\n from langchain_core.messages import AIMessageChunk\n\n c1 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"Let me \"})\n c2 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"think...\"})\n c3 = AIMessageChunk(content=\"Hello!\", additional_kwargs={})\n\n merged = c1 + c2 + c3\n assert merged.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n assert merged.content == \"Hello!\"\n\n def test_get_request_payload_injects_reasoning_content(self):\n \"\"\"Outbound: _get_request_payload injects reasoning_content from\n AIMessage.additional_kwargs into serialized assistant message dicts.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"hi\"),\n AIMessage(\n content=\"hello\",\n additional_kwargs={\"reasoning_content\": \"thinking...\"},\n ),\n ]\n\n # Mock super()._get_request_payload to return a payload without reasoning_content\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"hi\"},\n {\"role\": \"assistant\", \"content\": \"hello\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thinking...\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert payload[\"extra_body\"][\"thinking\"] == {\"type\": \"enabled\"}\n assert payload[\"reasoning_effort\"] == \"high\"\n\n def test_convert_chunk_empty_reasoning_content_ignored(self):\n \"\"\"Streaming: empty string reasoning_content in delta is ignored.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"hi\",\n \"reasoning_content\": \"\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n # Empty string is falsy — should not pollute additional_kwargs\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_none_delta_no_crash(self):\n \"\"\"Streaming: None delta in choices does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": None,\n \"finish_reason\": \"stop\",\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_empty_choices_no_crash(self):\n \"\"\"Streaming: empty choices array does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"choices\": []}\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_parent_returns_none(self):\n \"\"\"Streaming: when parent returns None, we return None too.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"type\": \"content.delta\"} # parent returns None for these\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=None,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is None\n\n def test_convert_chunk_beta_stream_format(self):\n \"\"\"Streaming: handles beta.chat.completions.stream nested chunk format.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Some LangChain versions nest under \"chunk\" key\n raw_chunk = {\n \"chunk\": {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Thinking via beta format...\",\n },\n \"finish_reason\": None,\n }\n ],\n },\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert (\n result.message.additional_kwargs[\"reasoning_content\"] == \"Thinking via beta format...\"\n )\n\n def test_get_request_payload_multiple_assistant_messages(self):\n \"\"\"Outbound: each assistant message gets its own reasoning_content.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"q1\"),\n AIMessage(content=\"a1\", additional_kwargs={\"reasoning_content\": \"thought1\"}),\n HumanMessage(content=\"q2\"),\n AIMessage(content=\"a2\", additional_kwargs={\"reasoning_content\": \"thought2\"}),\n ]\n\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"q1\"},\n {\"role\": \"assistant\", \"content\": \"a1\"},\n {\"role\": \"user\", \"content\": \"q2\"},\n {\"role\": \"assistant\", \"content\": \"a2\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thought1\"\n assert payload[\"messages\"][3][\"reasoning_content\"] == \"thought2\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert \"reasoning_content\" not in payload[\"messages\"][2]\n\n def test_model_detection(self):\n \"\"\"Factory routes DeepSeek V4 (pro + flash) and legacy reasoner.\n\n Per DeepSeek's API docs ``deepseek-reasoner`` is the deprecated\n alias for ``deepseek-v4-flash`` thinking mode, so v4-flash also\n returns ``reasoning_content`` and the API rejects subsequent\n tool turns when the field is omitted. Closes #201, #220.\n \"\"\"\n from decepticon.llm.factory import _model_is_deepseek_thinking\n\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-pro\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-flash\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-reasoner\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-chat\") is False\n assert _model_is_deepseek_thinking(\"openai/gpt-5.5\") is False\n\n\n# ── LLAMACPP_LOCAL credential detection (issue #151) ────────────────────\n\n\n_LLAMACPP_RELATED_ENV = (\n \"LLAMACPP_API_BASE\",\n \"LLAMACPP_MODEL\",\n \"LLAMACPP_API_KEY\",\n)\n\n\ndef _scrub_other_credentials(monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"Delete any env that would be detected as another credential.\n\n The credential-detection tests below need to assert \"only LLAMACPP\n is detected\"; without this scrub, a developer's exported\n ``ANTHROPIC_API_KEY`` would creep in and the assertion would fail\n locally only.\n \"\"\"\n for var in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"GROQ_API_KEY\",\n \"TOGETHER_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"COHERE_API_KEY\",\n \"MOONSHOT_API_KEY\",\n \"ZAI_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"GITHUB_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n ):\n monkeypatch.delenv(var, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n\nclass TestLlamacppLocalConfigured:\n \"\"\"``_llamacpp_local_configured`` is the gate for adding\n ``LLAMACPP_LOCAL`` to the credentials chain. Either env var being\n set is enough — neither requires the other, mirroring the\n LM Studio / Ollama detection contract.\n \"\"\"\n\n def test_returns_false_when_neither_env_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n assert _llamacpp_local_configured() is False\n\n def test_returns_true_when_only_base_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n assert _llamacpp_local_configured() is True\n\n def test_returns_true_when_only_model_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n assert _llamacpp_local_configured() is True\n\n def test_whitespace_does_not_count_as_set(self, monkeypatch):\n \"\"\"Stray ``LLAMACPP_API_BASE=`` lines in .env must not silently\n opt the user in — same fail-soft as the Ollama detector.\"\"\"\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \" \")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"\")\n assert _llamacpp_local_configured() is False\n\n\nclass TestResolveCredentialsForLlamacpp:\n \"\"\"End-to-end check that ``_resolve_credentials`` picks up the\n user's llama.cpp config — both the explicit-priority path and the\n \"only LLAMACPP_* detected\" auto-fallback at the bottom of\n ``_resolve_credentials``.\n \"\"\"\n\n def test_only_llamacpp_detected_returns_llamacpp_only_chain(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL]\n\n def test_priority_list_with_llamacpp_picks_it_up(self, monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"User wrote an explicit DECEPTICON_AUTH_PRIORITY listing\n llamacpp_local — the resolver must respect that ordering.\"\"\"\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen-coder\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"llamacpp_local,anthropic_api\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\nclass TestLLMTimeout:\n \"\"\"Whole-coroutine LLM request timeout (DECEPTICON_LLM_TIMEOUT_SECONDS).\"\"\"\n\n def test_call_with_timeout_raises_typed_exception(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> str:\n await asyncio.sleep(10)\n return \"done\"\n\n with pytest.raises(LLMTimeoutError, match=\"timed out after 0.01 seconds\"):\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n\n def test_call_with_timeout_preserves_original_timeout_as_cause(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def slow_call() -> None:\n await asyncio.sleep(10)\n\n try:\n asyncio.run(call_with_timeout(slow_call(), 0.01))\n except LLMTimeoutError as exc:\n assert isinstance(exc.__cause__, asyncio.TimeoutError)\n else:\n raise AssertionError(\"expected LLMTimeoutError\")\n\n def test_call_with_timeout_returns_successful_result(self) -> None:\n from decepticon.llm.factory import call_with_timeout\n\n async def fast_call() -> str:\n await asyncio.sleep(0)\n return \"ok\"\n\n assert asyncio.run(call_with_timeout(fast_call(), 1)) == \"ok\"\n\n def test_call_with_timeout_propagates_non_timeout_exceptions(self) -> None:\n from decepticon.llm.factory import LLMTimeoutError, call_with_timeout\n\n async def failing_call() -> None:\n raise RuntimeError(\"upstream 500\")\n\n with pytest.raises(RuntimeError, match=\"upstream 500\"):\n asyncio.run(call_with_timeout(failing_call(), 1))\n # And not as a timeout error\n try:\n asyncio.run(call_with_timeout(failing_call(), 1))\n except LLMTimeoutError:\n raise AssertionError(\"non-timeout exception was retyped\")\n except RuntimeError:\n pass\n\n def test_env_override_takes_effect(self, monkeypatch: pytest.MonkeyPatch) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"3\")\n assert _resolve_llm_timeout_seconds() == 3.0\n\n def test_default_timeout_is_600_when_env_absent(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.delenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", raising=False)\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_blank_env_falls_back_to_default(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \" \")\n assert _resolve_llm_timeout_seconds() == 600.0\n\n def test_invalid_env_raises_value_error(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"not-a-number\")\n with pytest.raises(ValueError):\n _resolve_llm_timeout_seconds()\n\n def test_non_positive_env_raises_value_error(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n from decepticon.llm.factory import _resolve_llm_timeout_seconds\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"0\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n\n monkeypatch.setenv(\"DECEPTICON_LLM_TIMEOUT_SECONDS\", \"-5\")\n with pytest.raises(ValueError, match=\"greater than 0\"):\n _resolve_llm_timeout_seconds()\n"}, "04d7fa11d42a2d51530936806a2faa7579d1f601": {"t": 1785755405.517229, "n": 48548, "h": "2ab4a3bb1ef2d2a0", "c": "\"\"\"Unit tests for decepticon.llm.factory.\"\"\"\n\nimport asyncio\nimport logging\n\nimport pytest\n\nfrom decepticon.llm.factory import (\n LLMFactory,\n _is_real_key,\n _llamacpp_local_configured,\n _oauth_credentials_present,\n _resolve_credentials,\n)\nfrom decepticon_core.types.llm import (\n AuthMethod,\n Credentials,\n LLMModelMapping,\n ModelProfile,\n ProxyConfig,\n)\n\n\nclass TestIsRealKey:\n \"\"\"Vendor-aware API key validation.\n\n The launcher writes ``your-…-key-here`` placeholders into .env so the\n user can later swap in a real key. The factory needs to reject those\n plus any obvious junk (short strings, ``placeholder``/``not-used``\n markers, vendor-prefix mismatches) so the credentials inventory\n reflects what actually works at request time.\n \"\"\"\n\n def test_rejects_empty_and_placeholder_template(self) -> None:\n assert _is_real_key(\"\") is False\n assert _is_real_key(\" \") is False\n assert _is_real_key(\"your-anthropic-key-here\") is False\n assert _is_real_key(\"YOUR-OPENAI-KEY-HERE\") is False # case-insensitive\n\n def test_rejects_short_strings(self) -> None:\n # Under 24 chars — every vendor-issued key exceeds this.\n assert _is_real_key(\"sk-tooshort\") is False\n\n def test_rejects_placeholder_tokens_in_value(self) -> None:\n long_enough = \"x\" * 30\n for token in (\"placeholder\", \"not-used\", \"dummy\", \"fake\", \"example\"):\n assert _is_real_key(f\"sk-{token}-{long_enough}\") is False, token\n\n def test_accepts_realistic_keys_without_method(self) -> None:\n # Without method context, prefix check is skipped.\n assert _is_real_key(\"sk-ant-api03-realtokenfortestingauthrouting12345\") is True\n assert _is_real_key(\"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\") is True\n\n def test_rejects_wrong_vendor_prefix(self) -> None:\n # An OpenAI-shaped key in the Anthropic slot must be caught.\n openai_key = \"sk-proj-realopenaitokenfortestingauthrouting12345\"\n assert _is_real_key(openai_key, AuthMethod.ANTHROPIC_API) is False\n # …and vice versa.\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.GOOGLE_API) is False\n\n def test_accepts_correct_vendor_prefix(self) -> None:\n anthropic_key = \"sk-ant-api03-realtokenfortestingauthrouting12345\"\n assert _is_real_key(anthropic_key, AuthMethod.ANTHROPIC_API) is True\n google_key = \"AIzaSyDeadBeefDeadBeefDeadBeefDeadBeef0\"\n assert _is_real_key(google_key, AuthMethod.GOOGLE_API) is True\n\n\nclass TestOAuthCredentialsPresent:\n \"\"\"OAuth detection requires the credential file alongside the boolean.\n\n Without the file check, ``DECEPTICON_AUTH_CLAUDE_CODE=true`` plus a\n deleted ``~/.claude/.credentials.json`` would still place OAuth in\n every fallback chain and generate one 401 per request.\n \"\"\"\n\n def test_returns_false_when_file_missing(self, monkeypatch, tmp_path) -> None:\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(tmp_path / \"absent.json\"))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_when_file_is_empty(self, monkeypatch, tmp_path) -> None:\n # ``/dev/null``-style mounts read as empty — must fail closed.\n empty = tmp_path / \"empty.json\"\n empty.write_text(\"\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(empty))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_false_on_invalid_json(self, monkeypatch, tmp_path) -> None:\n bad = tmp_path / \"bad.json\"\n bad.write_text(\"{not-json\")\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(bad))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is False\n\n def test_returns_true_when_file_is_well_formed(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"credentials.json\"\n good.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeef\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.ANTHROPIC_OAUTH) is True\n\n def test_codex_path_via_env_override(self, monkeypatch, tmp_path) -> None:\n good = tmp_path / \"auth.json\"\n good.write_text('{\"tokens\": {\"access_token\": \"ABC\"}}')\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CODEX_AUTH_PATH\", str(good))\n assert _oauth_credentials_present(AuthMethod.OPENAI_OAUTH) is True\n\n\nclass TestLLMFactory:\n def setup_method(self):\n self.proxy = ProxyConfig(url=\"http://localhost:4000\", api_key=\"test-key\")\n # Build an explicit mapping so the test doesn't depend on env vars.\n creds = Credentials.all_api_methods()\n self.mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n self.factory = LLMFactory(self.proxy, self.mapping)\n\n def test_factory_initializes(self):\n assert self.factory.proxy_url == \"http://localhost:4000\"\n\n def test_get_model_returns_chat_model(self):\n model = self.factory.get_model(\"recon\")\n assert model is not None\n assert model.model_name == \"anthropic/claude-haiku-4-5\"\n\n def test_get_model_caches_instances(self):\n m1 = self.factory.get_model(\"recon\")\n m2 = self.factory.get_model(\"recon\")\n assert m1 is m2\n\n def test_get_model_different_roles_different_models(self):\n recon = self.factory.get_model(\"recon\")\n decepticon = self.factory.get_model(\"decepticon\")\n assert recon is not decepticon\n assert recon.model_name != decepticon.model_name\n\n def test_get_model_unknown_role_raises(self):\n with pytest.raises(KeyError, match=\"No model assignment\"):\n self.factory.get_model(\"nonexistent\")\n\n def test_router_accessible(self):\n assert self.factory.router is not None\n\n def test_get_fallback_models_full_chain(self):\n models = self.factory.get_fallback_models(\"recon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5-nano\",\n \"gemini/gemini-2.5-flash-lite\",\n \"deepseek/deepseek-v4-flash\",\n \"openrouter/anthropic/claude-haiku-4-5\",\n \"nvidia_nim/meta/llama-3.2-3b-instruct\",\n ]\n\n def test_get_fallback_models_high_tier_includes_all_methods(self):\n models = self.factory.get_fallback_models(\"decepticon\")\n names = [m.model_name for m in models]\n assert names == [\n \"openai/gpt-5.5\",\n \"gemini/gemini-2.5-pro\",\n \"minimax/MiniMax-M2.5\",\n \"deepseek/deepseek-v4-pro\",\n \"xai/grok-4.3\",\n \"mistral/mistral-large-latest\",\n \"openrouter/anthropic/claude-opus-4-7\",\n \"nvidia_nim/meta/llama-3.3-70b-instruct\",\n ]\n\n def test_get_fallback_models_without_fallback(self):\n # Single-credential mapping → no fallback.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n mapping = LLMModelMapping.from_credentials_and_profile(creds, ModelProfile.ECO)\n factory = LLMFactory(self.proxy, mapping)\n assert factory.get_fallback_models(\"recon\") == []\n\n def test_explicit_credentials_param(self):\n # Constructor accepts a Credentials object instead of a full mapping.\n creds = Credentials(methods=[AuthMethod.OPENAI_API])\n factory = LLMFactory(self.proxy, credentials=creds, profile=ModelProfile.ECO)\n assert factory.get_model(\"decepticon\").model_name == \"openai/gpt-5.5\"\n\n\nclass TestLLMFactoryHealthCheck:\n def test_health_check_returns_false_when_no_proxy(self):\n proxy = ProxyConfig(url=\"http://localhost:19999\")\n factory = LLMFactory(proxy, mapping=LLMModelMapping())\n assert asyncio.run(factory.health_check()) is False\n\n\nclass TestResolveCredentials:\n def test_real_keys_only(self, monkeypatch):\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"your-openai-key-here\") # placeholder\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_API]\n\n def test_oauth_only(self, monkeypatch, tmp_path):\n # OAuth detection requires the credential FILE alongside the\n # boolean — point Claude Code at a temp credentials file so the\n # test runs deterministically regardless of host state.\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.ANTHROPIC_OAUTH]\n\n def test_oauth_flag_without_credential_file_is_dropped(self, monkeypatch, tmp_path):\n \"\"\"Stale ``DECEPTICON_AUTH_CLAUDE_CODE=true`` after ``codex logout``\n (or after the user deleted ``~/.claude/.credentials.json``) must\n not place the OAuth method into the chain — otherwise every\n request 401s before falling back to the next provider.\n \"\"\"\n # Point both the primary and legacy fallback paths at tmp_path so\n # any ``~/.claude/.credentials.json`` or\n # ``~/.config/anthropic/q/tokens.json`` on the dev host doesn't\n # accidentally satisfy the file-presence check.\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n missing = tmp_path / \"missing.json\" # never created\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n creds = _resolve_credentials()\n assert AuthMethod.ANTHROPIC_OAUTH not in creds.methods\n\n def test_oauth_plus_api_priority_default(self, monkeypatch, tmp_path):\n # Default priority is anthropic_oauth > anthropic_api > openai_api ...\n cred_file = tmp_path / \"credentials.json\"\n cred_file.write_text('{\"claudeAiOauth\": {\"accessToken\": \"sk-ant-oat01-deadbeefdeadbeef\"}}')\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(cred_file))\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n for k in (\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_OAUTH,\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n ]\n\n def test_explicit_priority_override(self, monkeypatch):\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"openai_api,anthropic_api\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-proj-realopenaitokenfortestingauthrouting12345\")\n monkeypatch.delenv(\"GEMINI_API_KEY\", raising=False)\n monkeypatch.delenv(\"MINIMAX_API_KEY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OPENAI_API, AuthMethod.ANTHROPIC_API]\n\n def test_placeholder_falls_back_to_all_api_methods(self, monkeypatch):\n \"\"\"When every detected method is a placeholder/missing, the resolver\n falls back to the all-API-methods inventory so module-level agent\n constructors stay importable in CI / dev shells without keys.\"\"\"\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"your-anthropic-key-here\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [\n AuthMethod.ANTHROPIC_API,\n AuthMethod.OPENAI_API,\n AuthMethod.GOOGLE_API,\n AuthMethod.MINIMAX_API,\n AuthMethod.DEEPSEEK_API,\n AuthMethod.XAI_API,\n AuthMethod.MISTRAL_API,\n AuthMethod.OPENROUTER_API,\n AuthMethod.NVIDIA_API,\n ]\n\n def test_explicit_priority_no_creds_logs_error(self, monkeypatch, tmp_path, caplog):\n \"\"\"An explicit ``DECEPTICON_AUTH_PRIORITY`` whose every listed\n method fails detection (typical of a broken OAuth credentials\n mount — e.g. ``CLAUDE_CREDENTIALS_VOLUME`` unset and the\n compose file bound ``/dev/null`` into the container) must\n surface the root cause at ERROR level.\n\n Otherwise the silent fallback to ``all_api_methods()`` runs\n through nine unrelated providers and the operator only sees a\n downstream 401-cascade — confusingly masked as a 429 once the\n routed-to provider (e.g. NVIDIA NIM) cools down. Backward\n compat is preserved: the resolver still returns\n ``all_api_methods()`` so module imports remain green; the\n real model call surfaces a separate, actionable error via\n ``_reraise_with_actionable_message``.\n \"\"\"\n # Point the OAuth credential path at a file we never create so\n # detection fails the same way ``/dev/null`` does at runtime.\n missing = tmp_path / \"missing.json\"\n monkeypatch.setenv(\"HOME\", str(tmp_path))\n monkeypatch.setenv(\"CLAUDE_CODE_CREDENTIALS_PATH\", str(missing))\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"anthropic_oauth\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", \"true\")\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n ):\n monkeypatch.delenv(k, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n # ``decepticon_core.utils.logging`` sets the parent decepticon logger\n # to ``propagate=False`` so library output doesn't double up in\n # apps that own their root handler. caplog hooks the root logger,\n # so without re-enabling propagation our ERROR record never\n # reaches pytest's capture buffer. Re-enable for the duration of\n # the call and let monkeypatch restore on teardown.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.ERROR):\n creds = _resolve_credentials()\n\n # Backward compat: still returns the all-API-methods fallback.\n assert creds.methods == Credentials.all_api_methods().methods\n\n # ERROR diagnostic must mention the env var name so an operator\n # scanning ``decepticon logs langgraph`` knows where to look.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert any(\"DECEPTICON_AUTH_PRIORITY\" in r.getMessage() for r in error_records), (\n f\"No ERROR log mentioned DECEPTICON_AUTH_PRIORITY. \"\n f\"Got: {[r.getMessage() for r in error_records]}\"\n )\n\n def test_implicit_priority_no_creds_stays_info_level(self, monkeypatch, caplog):\n \"\"\"Implicit priority (no DECEPTICON_AUTH_PRIORITY set) with no\n detectable credentials is the import-friendly CI/test path:\n we keep the existing INFO log and ``all_api_methods()`` return\n so module imports work without keys present. Only **explicit**\n priority with no matches gets escalated to ERROR.\n \"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_PRIORITY\", raising=False)\n monkeypatch.delenv(\"DECEPTICON_AUTH_CLAUDE_CODE\", raising=False)\n\n # Match the explicit-priority test's pattern so caplog sees the\n # records — the decepticon parent logger defaults to\n # ``propagate=False`` per ``decepticon_core.utils.logging``.\n decepticon_log = logging.getLogger(\"decepticon\")\n monkeypatch.setattr(decepticon_log, \"propagate\", True)\n\n with caplog.at_level(logging.DEBUG):\n creds = _resolve_credentials()\n\n assert creds.methods == Credentials.all_api_methods().methods\n # No ERROR records — this case stays informational.\n error_records = [r for r in caplog.records if r.levelname == \"ERROR\"]\n assert not error_records, (\n f\"Unexpected ERROR logs in implicit-priority path: \"\n f\"{[r.getMessage() for r in error_records]}\"\n )\n\n def test_ollama_local_only_returns_ollama_chain(self, monkeypatch):\n \"\"\"Issue #106: a user with only OLLAMA_API_BASE / OLLAMA_MODEL set\n (no API keys, no OAuth) must get a chain of one — Ollama only.\n Falling back to all-API-methods would produce 401 errors on every\n provider the user doesn't have.\"\"\"\n for k in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL]\n\n def test_explicit_priority_with_ollama_local(self, monkeypatch):\n \"\"\"User opts into Ollama via explicit priority — the resolver\n recognizes it as configured when OLLAMA_API_BASE is set.\"\"\"\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"ollama_local,anthropic_api\")\n monkeypatch.setenv(\"OLLAMA_API_BASE\", \"http://host.docker.internal:11434\")\n monkeypatch.setenv(\"OLLAMA_MODEL\", \"qwen3-coder:30b\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n for k in (\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n ):\n monkeypatch.delenv(k, raising=False)\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.OLLAMA_LOCAL, AuthMethod.ANTHROPIC_API]\n\n\n# ── Temperature handling (issue #107) ───────────────────────────────────\n\n\nclass TestTemperatureDrop:\n \"\"\"Claude Opus 4.7 rejects ``temperature`` regardless of route. The\n factory must drop it on every Opus 4 surface (anthropic/, auth/,\n openrouter/anthropic/) and keep it for everyone else.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _model_drops_temperature\n\n self._drops = _model_drops_temperature\n\n def test_anthropic_opus_drops_temperature(self):\n assert self._drops(\"anthropic/claude-opus-4-7\") is True\n\n def test_oauth_opus_drops_temperature(self):\n assert self._drops(\"auth/claude-opus-4-7\") is True\n\n def test_openrouter_opus_drops_temperature(self):\n assert self._drops(\"openrouter/anthropic/claude-opus-4-7\") is True\n\n def test_sonnet_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-sonnet-4-6\") is False\n\n def test_haiku_keeps_temperature(self):\n assert self._drops(\"anthropic/claude-haiku-4-5\") is False\n\n def test_openai_keeps_temperature(self):\n assert self._drops(\"openai/gpt-5.5\") is False\n\n def test_ollama_keeps_temperature(self):\n assert self._drops(\"ollama_chat/qwen3-coder:30b\") is False\n\n\n# ── Actionable error translation (issue #107 + community feedback) ──────\n\n\nclass TestActionableErrorTranslation:\n \"\"\"The OSS user complaint: every upstream failure surfaces as 'An\n internal error occurred', stripping the message that would tell them\n what to fix. Each branch below verifies one class of error gets\n rewritten with a remediation hint and the model id that hit the\n failure.\"\"\"\n\n def setup_method(self):\n from decepticon.llm.factory import _reraise_with_actionable_message\n\n self._translate = _reraise_with_actionable_message\n\n def test_no_fallback_model_group_branch(self):\n exc = Exception(\n \"litellm.BadRequestError: ... No fallback model group found \"\n \"for original model_group=anthropic/claude-opus-4-7.\"\n )\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"no provider fallback\" in msg\n assert \"anthropic/claude-opus-4-7\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_400_bad_request_branch(self):\n # openai.BadRequestError carries 'Error code: 400' in repr.\n exc = Exception(\"Error code: 400 - {'error': {'message': 'temperature is deprecated'}}\")\n type(exc).__name__ # noqa: B018 — sanity, ensures Exception default name\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n assert \"rejected the request (400)\" in str(info.value)\n\n def test_401_authentication_branch(self):\n exc = type(\"AuthenticationError\", (Exception,), {})(\"Error code: 401 - invalid_api_key\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"openai/gpt-5.5\")\n msg = str(info.value)\n assert \"credentials (401)\" in msg\n assert \"decepticon onboard --reset\" in msg\n\n def test_429_ratelimit_branch(self):\n exc = type(\"RateLimitError\", (Exception,), {})(\"Error code: 429\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n msg = str(info.value)\n assert \"rate limit (429)\" in msg\n assert \"DECEPTICON_AUTH_PRIORITY\" in msg\n\n def test_404_notfound_with_ollama_hint(self):\n exc = type(\"NotFoundError\", (Exception,), {})(\"Error code: 404 - model not found\")\n with pytest.raises(RuntimeError) as info:\n self._translate(exc, \"ollama_chat/nonexistent\")\n msg = str(info.value)\n assert \"404\" in msg\n assert \"OLLAMA_MODEL\" in msg\n\n def test_unmatched_error_passes_through(self):\n # Anything we don't recognize must NOT raise — the caller's\n # ``raise`` follows and re-raises the original exception with\n # full traceback.\n exc = ValueError(\"something completely unrelated\")\n # Should not raise from the helper.\n self._translate(exc, \"anthropic/claude-opus-4-7\")\n\n\n# ── DeepSeek V4 Pro reasoning_content passthrough ────────────────────────\n\n\nclass TestDeepSeekReasoningContent:\n \"\"\"Verify reasoning_content survives both the non-streaming and streaming\n paths so it can be sent back on subsequent API turns.\"\"\"\n\n def test_create_chat_result_extracts_reasoning_content(self):\n \"\"\"Non-streaming: _create_chat_result captures reasoning_content\n from the response dict's choice message.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Simulate the OpenAI response dict with reasoning_content\n response_dict = {\n \"choices\": [\n {\n \"message\": {\n \"role\": \"assistant\",\n \"content\": \"Four\",\n \"reasoning_content\": \"I think therefore I am\",\n },\n \"finish_reason\": \"stop\",\n }\n ],\n \"usage\": {},\n }\n\n # Mock the parent's _create_chat_result to return a ChatResult without reasoning_content\n msg = AIMessage(content=\"Four\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert (\n result.generations[0].message.additional_kwargs[\"reasoning_content\"]\n == \"I think therefore I am\"\n )\n\n def test_create_chat_result_skips_when_absent(self):\n \"\"\"Non-streaming: no crash when reasoning_content is missing.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage\n from langchain_core.outputs import ChatGeneration, ChatResult\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n response_dict = {\n \"choices\": [\n {\"message\": {\"role\": \"assistant\", \"content\": \"hi\"}, \"finish_reason\": \"stop\"}\n ],\n \"usage\": {},\n }\n\n msg = AIMessage(content=\"hi\", additional_kwargs={})\n parent_result = ChatResult(generations=[ChatGeneration(message=msg)])\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_create_chat_result\",\n return_value=parent_result,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n result = instance._create_chat_result(response_dict)\n\n assert \"reasoning_content\" not in result.generations[0].message.additional_kwargs\n\n def test_convert_chunk_injects_reasoning_content(self):\n \"\"\"Streaming: _convert_chunk_to_generation_chunk captures\n reasoning_content from the raw delta and injects it into\n AIMessageChunk.additional_kwargs.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Build a fake raw SSE chunk dict with reasoning_content in the delta\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Let me think...\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n # The parent's _convert_chunk_to_generation_chunk builds the\n # ChatGenerationChunk but ignores reasoning_content. We mock it\n # to return a chunk without reasoning_content, then verify our\n # override injects it.\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n # Bind the real method to our mock instance\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert result.message.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n\n def test_convert_chunk_no_reasoning_leaves_kwargs_clean(self):\n \"\"\"Streaming: when delta has no reasoning_content, additional_kwargs\n is not polluted.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\"role\": \"assistant\", \"content\": \"hi\"},\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_reasoning_content_accumulates_across_chunks(self):\n \"\"\"Streaming: reasoning_content from multiple chunks concatenates\n via AIMessageChunk.__add__ (merge_dicts).\"\"\"\n from langchain_core.messages import AIMessageChunk\n\n c1 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"Let me \"})\n c2 = AIMessageChunk(content=\"\", additional_kwargs={\"reasoning_content\": \"think...\"})\n c3 = AIMessageChunk(content=\"Hello!\", additional_kwargs={})\n\n merged = c1 + c2 + c3\n assert merged.additional_kwargs[\"reasoning_content\"] == \"Let me think...\"\n assert merged.content == \"Hello!\"\n\n def test_get_request_payload_injects_reasoning_content(self):\n \"\"\"Outbound: _get_request_payload injects reasoning_content from\n AIMessage.additional_kwargs into serialized assistant message dicts.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"hi\"),\n AIMessage(\n content=\"hello\",\n additional_kwargs={\"reasoning_content\": \"thinking...\"},\n ),\n ]\n\n # Mock super()._get_request_payload to return a payload without reasoning_content\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"hi\"},\n {\"role\": \"assistant\", \"content\": \"hello\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thinking...\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert payload[\"extra_body\"][\"thinking\"] == {\"type\": \"enabled\"}\n assert payload[\"reasoning_effort\"] == \"high\"\n\n def test_convert_chunk_empty_reasoning_content_ignored(self):\n \"\"\"Streaming: empty string reasoning_content in delta is ignored.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"hi\",\n \"reasoning_content\": \"\",\n },\n \"finish_reason\": None,\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"hi\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n # Empty string is falsy — should not pollute additional_kwargs\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_none_delta_no_crash(self):\n \"\"\"Streaming: None delta in choices does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\n \"choices\": [\n {\n \"delta\": None,\n \"finish_reason\": \"stop\",\n }\n ],\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_empty_choices_no_crash(self):\n \"\"\"Streaming: empty choices array does not crash.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"choices\": []}\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is not None\n assert \"reasoning_content\" not in result.message.additional_kwargs\n\n def test_convert_chunk_parent_returns_none(self):\n \"\"\"Streaming: when parent returns None, we return None too.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n raw_chunk = {\"type\": \"content.delta\"} # parent returns None for these\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=None,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert result is None\n\n def test_convert_chunk_beta_stream_format(self):\n \"\"\"Streaming: handles beta.chat.completions.stream nested chunk format.\"\"\"\n from unittest.mock import MagicMock, patch\n\n from langchain_core.messages import AIMessageChunk\n from langchain_core.outputs import ChatGenerationChunk\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n # Some LangChain versions nest under \"chunk\" key\n raw_chunk = {\n \"chunk\": {\n \"choices\": [\n {\n \"delta\": {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"reasoning_content\": \"Thinking via beta format...\",\n },\n \"finish_reason\": None,\n }\n ],\n },\n }\n\n base_msg = AIMessageChunk(content=\"\", additional_kwargs={})\n base_chunk = ChatGenerationChunk(message=base_msg)\n instance = MagicMock(spec=_DeepSeekThinkingChatOpenAI)\n method = _DeepSeekThinkingChatOpenAI._convert_chunk_to_generation_chunk\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_convert_chunk_to_generation_chunk\",\n return_value=base_chunk,\n ):\n result = method(instance, raw_chunk, AIMessageChunk, None)\n\n assert (\n result.message.additional_kwargs[\"reasoning_content\"] == \"Thinking via beta format...\"\n )\n\n def test_get_request_payload_multiple_assistant_messages(self):\n \"\"\"Outbound: each assistant message gets its own reasoning_content.\"\"\"\n from unittest.mock import patch\n\n from langchain_core.messages import AIMessage, HumanMessage\n\n from decepticon.llm.factory import _DeepSeekThinkingChatOpenAI\n\n messages = [\n HumanMessage(content=\"q1\"),\n AIMessage(content=\"a1\", additional_kwargs={\"reasoning_content\": \"thought1\"}),\n HumanMessage(content=\"q2\"),\n AIMessage(content=\"a2\", additional_kwargs={\"reasoning_content\": \"thought2\"}),\n ]\n\n mock_payload = {\n \"messages\": [\n {\"role\": \"user\", \"content\": \"q1\"},\n {\"role\": \"assistant\", \"content\": \"a1\"},\n {\"role\": \"user\", \"content\": \"q2\"},\n {\"role\": \"assistant\", \"content\": \"a2\"},\n ],\n \"model\": \"deepseek/deepseek-v4-pro\",\n }\n\n with patch.object(\n _DeepSeekThinkingChatOpenAI.__bases__[0],\n \"_get_request_payload\",\n return_value=mock_payload,\n ):\n instance = object.__new__(_DeepSeekThinkingChatOpenAI)\n payload = instance._get_request_payload(messages)\n\n assert payload[\"messages\"][1][\"reasoning_content\"] == \"thought1\"\n assert payload[\"messages\"][3][\"reasoning_content\"] == \"thought2\"\n assert \"reasoning_content\" not in payload[\"messages\"][0]\n assert \"reasoning_content\" not in payload[\"messages\"][2]\n\n def test_model_detection(self):\n \"\"\"Factory routes DeepSeek V4 (pro + flash) and legacy reasoner.\n\n Per DeepSeek's API docs ``deepseek-reasoner`` is the deprecated\n alias for ``deepseek-v4-flash`` thinking mode, so v4-flash also\n returns ``reasoning_content`` and the API rejects subsequent\n tool turns when the field is omitted. Closes #201, #220.\n \"\"\"\n from decepticon.llm.factory import _model_is_deepseek_thinking\n\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-pro\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-v4-flash\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-reasoner\") is True\n assert _model_is_deepseek_thinking(\"deepseek/deepseek-chat\") is False\n assert _model_is_deepseek_thinking(\"openai/gpt-5.5\") is False\n\n\n# ── LLAMACPP_LOCAL credential detection (issue #151) ────────────────────\n\n\n_LLAMACPP_RELATED_ENV = (\n \"LLAMACPP_API_BASE\",\n \"LLAMACPP_MODEL\",\n \"LLAMACPP_API_KEY\",\n)\n\n\ndef _scrub_other_credentials(monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"Delete any env that would be detected as another credential.\n\n The credential-detection tests below need to assert \"only LLAMACPP\n is detected\"; without this scrub, a developer's exported\n ``ANTHROPIC_API_KEY`` would creep in and the assertion would fail\n locally only.\n \"\"\"\n for var in (\n \"ANTHROPIC_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"XAI_API_KEY\",\n \"MISTRAL_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"GROQ_API_KEY\",\n \"TOGETHER_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"COHERE_API_KEY\",\n \"MOONSHOT_API_KEY\",\n \"ZAI_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"GITHUB_API_KEY\",\n \"OLLAMA_API_BASE\",\n \"OLLAMA_MODEL\",\n \"OLLAMA_CLOUD_API_BASE\",\n \"OLLAMA_CLOUD_MODEL\",\n \"LMSTUDIO_API_BASE\",\n \"LMSTUDIO_MODEL\",\n \"CUSTOM_OPENAI_API_BASE\",\n \"CUSTOM_OPENAI_API_KEY\",\n \"DECEPTICON_AUTH_PRIORITY\",\n ):\n monkeypatch.delenv(var, raising=False)\n for flag in (\n \"DECEPTICON_AUTH_CLAUDE_CODE\",\n \"DECEPTICON_AUTH_CHATGPT\",\n \"DECEPTICON_AUTH_COPILOT\",\n \"DECEPTICON_AUTH_GEMINI\",\n \"DECEPTICON_AUTH_GROK\",\n \"DECEPTICON_AUTH_PERPLEXITY\",\n ):\n monkeypatch.delenv(flag, raising=False)\n\n\nclass TestLlamacppLocalConfigured:\n \"\"\"``_llamacpp_local_configured`` is the gate for adding\n ``LLAMACPP_LOCAL`` to the credentials chain. Either env var being\n set is enough — neither requires the other, mirroring the\n LM Studio / Ollama detection contract.\n \"\"\"\n\n def test_returns_false_when_neither_env_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n assert _llamacpp_local_configured() is False\n\n def test_returns_true_when_only_base_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n assert _llamacpp_local_configured() is True\n\n def test_returns_true_when_only_model_set(self, monkeypatch):\n for var in _LLAMACPP_RELATED_ENV:\n monkeypatch.delenv(var, raising=False)\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n assert _llamacpp_local_configured() is True\n\n def test_whitespace_does_not_count_as_set(self, monkeypatch):\n \"\"\"Stray ``LLAMACPP_API_BASE=`` lines in .env must not silently\n opt the user in — same fail-soft as the Ollama detector.\"\"\"\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \" \")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"\")\n assert _llamacpp_local_configured() is False\n\n\nclass TestResolveCredentialsForLlamacpp:\n \"\"\"End-to-end check that ``_resolve_credentials`` picks up the\n user's llama.cpp config — both the explicit-priority path and the\n \"only LLAMACPP_* detected\" auto-fallback at the bottom of\n ``_resolve_credentials``.\n \"\"\"\n\n def test_only_llamacpp_detected_returns_llamacpp_only_chain(\n self, monkeypatch: pytest.MonkeyPatch\n ) -> None:\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen2.5-coder-7b-instruct-q4_k_m\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL]\n\n def test_priority_list_with_llamacpp_picks_it_up(self, monkeypatch: pytest.MonkeyPatch) -> None:\n \"\"\"User wrote an explicit DECEPTICON_AUTH_PRIORITY listing\n llamacpp_local — the resolver must respect that ordering.\"\"\"\n _scrub_other_credentials(monkeypatch)\n monkeypatch.setenv(\"LLAMACPP_API_BASE\", \"http://localhost:8080/v1\")\n monkeypatch.setenv(\"LLAMACPP_MODEL\", \"qwen-coder\")\n monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-api03-realtokenfortestingauthrouting12345\")\n monkeypatch.setenv(\"DECEPTICON_AUTH_PRIORITY\", \"llamacpp_local,anthropic_api\")\n\n creds = _resolve_credentials()\n assert creds.methods == [AuthMethod.LLAMACPP_LOCAL, AuthMethod.ANTHROPIC_API]\n"}}, "packages/decepticon/decepticon/navmax/ai/engine.py": {"42b918525fb79065e730da3da997bffb33fcd414": {"t": 1786068484.0783935, "n": 15393, "h": "8ac7e254c0320c33", "c": "\"\"\"AIEngine — Orchestrateur multi-provider avec sélection automatique.\n\nCœur du module IA de NavMAX. Combine :\n- Détection hardware automatique\n- Tous les providers (Ollama, llama.cpp, LM Studio, OpenAI, Anthropic, DeepSeek)\n- ModelSelector pour choisir le meilleur modèle par tâche\n- Fallback automatique : local → cloud, Heavy → Medium → Light\n- Mode airgap (désactive tous les providers cloud)\n- Lecture des API keys depuis ~/.navmax/api_keys.yaml\n\nUsage:\n engine = AIEngine()\n status = await engine.initialize()\n result = await engine.generate(\"Analyse ce scan TCP\", tier=ModelTier.MEDIUM)\n\"\"\"\n\nimport asyncio\nimport os\nfrom collections.abc import AsyncIterator\nfrom pathlib import Path\nfrom typing import Optional\n\nimport structlog\nimport yaml\n\nfrom decepticon.navmax.ai.hardware import HardwareProfile, detect_hardware\nfrom decepticon.navmax.ai.providers.base import (\n BaseProvider,\n GenerateParams,\n GenerateResult,\n ModelTier,\n ProviderType,\n)\nfrom decepticon.navmax.ai.selector import ModelSelector\n\nlogger = structlog.get_logger(__name__)\n\n# Singleton global\n_engine: Optional[\"AIEngine\"] = None\n\n\ndef get_engine() -> \"AIEngine\":\n \"\"\"Retourne l'instance singleton de AIEngine.\"\"\"\n global _engine\n if _engine is None:\n _engine = AIEngine()\n return _engine\n\n\nclass AIEngine:\n \"\"\"Moteur IA multi-provider avec sélection automatique de modèle.\n\n Détecte le hardware, scanne les providers, et sélectionne\n automatiquement le meilleur modèle pour chaque tâche.\n\n Parameters\n ----------\n airgap: Si True, désactive tous les providers cloud\n prefer_uncensored: Si True, préfère les modèles abliterated\n\n \"\"\"\n\n def __init__(self, airgap: bool = False, prefer_uncensored: bool = True) -> None:\n self.airgap = airgap\n self.prefer_uncensored = prefer_uncensored\n self._providers: dict[ProviderType, BaseProvider] = {}\n self._hw: HardwareProfile | None = None\n self._selector: ModelSelector | None = None\n self._initialized = False\n\n # ── API Keys from YAML ─────────────────────────────────────\n\n API_KEYS_PATH = Path.home() / \".navmax\" / \"api_keys.yaml\"\n\n @staticmethod\n def _load_api_keys() -> dict[str, str]:\n \"\"\"Charge les API keys depuis ~/.navmax/api_keys.yaml.\n\n Retourne un dict {provider_name: api_key}.\n Priorité : YAML > variable d'environnement > vide.\n \"\"\"\n keys: dict[str, str] = {}\n\n # Essayer le fichier YAML d'abord\n yaml_path = AIEngine.API_KEYS_PATH\n if yaml_path.exists():\n try:\n content = yaml_path.read_text(encoding=\"utf-8\")\n data = yaml.safe_load(content)\n if isinstance(data, dict) and \"providers\" in data:\n for provider, key in data[\"providers\"].items():\n if key and isinstance(key, str):\n keys[provider] = key\n except Exception:\n logger.warning(\"api_keys_yaml_lecture_erreur\", path=str(yaml_path))\n\n # Fallback sur les variables d'environnement si pas trouvé dans YAML\n env_map = {\n \"openai\": \"OPENAI_API_KEY\",\n \"anthropic\": \"ANTHROPIC_API_KEY\",\n \"deepseek\": \"DEEPSEEK_API_KEY\",\n }\n for provider, env_var in env_map.items():\n if provider not in keys:\n env_val = os.environ.get(env_var, \"\")\n if env_val:\n keys[provider] = env_val\n\n return keys\n\n # ── Initialization ──────────────────────────────────────────\n\n async def initialize(self) -> dict:\n \"\"\"Initialise tous les providers et le sélecteur. Retourne un rapport.\"\"\"\n if self._initialized:\n return {\"status\": \"already_initialized\"}\n\n self._hw = detect_hardware()\n\n # Initialiser tous les providers\n status = {\n \"hardware\": {\n \"ram_gb\": self._hw.ram_total_gb,\n \"gpu\": self._hw.gpu_name,\n \"max_local_tier\": self._hw.max_local_tier,\n },\n \"providers\": {},\n \"airgap\": self.airgap,\n }\n\n # Providers locaux\n local_configs = [\n (\"ollama\", self._init_ollama),\n (\"lmstudio\", self._init_lmstudio),\n ]\n for name, init_fn in local_configs:\n provider = await init_fn()\n if provider and await provider.health_check():\n self._providers[provider.provider_type] = provider\n models = await provider.list_models()\n status[\"providers\"][name] = {\n \"status\": \"available\",\n \"models_count\": len(models),\n \"models\": [m.name for m in models[:10]], # top 10\n }\n else:\n status[\"providers\"][name] = {\"status\": \"unavailable\"}\n\n # llama.cpp (optionnel, nécessite un chemin de modèle)\n if not self.airgap:\n # Cloud providers\n cloud_configs = [\n (\"openai\", self._init_openai),\n (\"anthropic\", self._init_anthropic),\n (\"deepseek\", self._init_deepseek),\n ]\n for name, init_fn in cloud_configs:\n provider = await init_fn()\n if provider and await provider.health_check():\n self._providers[provider.provider_type] = provider\n status[\"providers\"][name] = {\"status\": \"available\"}\n else:\n status[\"providers\"][name] = {\"status\": \"unavailable\"}\n else:\n status[\"providers\"][\"openai\"] = {\"status\": \"disabled (airgap)\"}\n status[\"providers\"][\"anthropic\"] = {\"status\": \"disabled (airgap)\"}\n status[\"providers\"][\"deepseek\"] = {\"status\": \"disabled (airgap)\"}\n\n # Initialiser le sélecteur\n self._selector = ModelSelector(\n hardware=self._hw,\n prefer_uncensored=self.prefer_uncensored,\n airgap=self.airgap,\n )\n\n if self._providers:\n report = await self._selector.scan(self._providers)\n status[\"selector\"] = self._selector.get_status()\n status[\"recommendations\"] = report.recommendations\n\n self._initialized = True\n logger.info(\"ai_engine_initialized\", **status[\"hardware\"])\n return status\n\n async def _init_ollama(self) -> BaseProvider | None:\n try:\n from decepticon.navmax.ai.providers.ollama import OllamaProvider\n\n return OllamaProvider()\n except ImportError:\n return None\n\n async def _init_lmstudio(self) -> BaseProvider | None:\n try:\n from decepticon.navmax.ai.providers.lmstudio import LMStudioProvider\n\n return LMStudioProvider()\n except ImportError:\n return None\n\n async def _init_openai(self) -> BaseProvider | None:\n keys = self._load_api_keys()\n api_key = keys.get(\"openai\", \"\")\n if not api_key:\n return None\n try:\n from decepticon.navmax.ai.providers.openai_compat import OpenAICompatProvider\n\n return OpenAICompatProvider(\n provider_type=ProviderType.OPENAI,\n base_url=\"https://api.openai.com/v1\",\n api_key=api_key,\n )\n except ImportError:\n return None\n\n async def _init_anthropic(self) -> BaseProvider | None:\n keys = self._load_api_keys()\n api_key = keys.get(\"anthropic\", \"\")\n if not api_key:\n return None\n try:\n from decepticon.navmax.ai.providers.openai_compat import OpenAICompatProvider\n\n return OpenAICompatProvider(\n provider_type=ProviderType.ANTHROPIC,\n base_url=\"https://api.anthropic.com\",\n api_key=api_key,\n )\n except ImportError:\n return None\n\n async def _init_deepseek(self) -> BaseProvider | None:\n keys = self._load_api_keys()\n api_key = keys.get(\"deepseek\", \"\")\n if not api_key:\n return None\n try:\n from decepticon.navmax.ai.providers.openai_compat import OpenAICompatProvider\n\n return OpenAICompatProvider(\n provider_type=ProviderType.DEEPSEEK,\n base_url=\"https://api.deepseek.com/v1\",\n api_key=api_key,\n )\n except ImportError:\n return None\n\n # ── Generation API ──────────────────────────────────────────\n\n async def generate(\n self,\n prompt: str,\n *,\n tier: ModelTier = ModelTier.MEDIUM,\n system: str | None = None,\n max_tokens: int = 2048,\n temperature: float = 0.7,\n json_mode: bool = False,\n provider: ProviderType | None = None,\n model: str | None = None,\n ) -> GenerateResult:\n \"\"\"Génère du texte avec sélection automatique du meilleur modèle.\n\n Args:\n prompt: Le prompt utilisateur\n tier: Niveau de capacité requis (défaut: MEDIUM pour planification)\n system: System prompt optionnel\n max_tokens: Tokens max en sortie\n temperature: Créativité (0.0 = déterministe, 1.0 = créatif)\n json_mode: Force la sortie en JSON\n provider: Force un provider spécifique\n model: Force un modèle spécifique\n\n Returns:\n GenerateResult avec le texte généré et les métadonnées\n\n \"\"\"\n if not self._initialized:\n await self.initialize()\n\n params = GenerateParams(\n prompt=prompt,\n system=system,\n max_tokens=max_tokens,\n temperature=temperature,\n json_mode=json_mode,\n )\n\n # Sélection du provider et modèle\n if model and provider:\n # Forçage explicite\n prov = self._providers.get(provider)\n if not prov:\n msg = f\"Provider {provider.value} not available\"\n raise RuntimeError(msg)\n params.model = model\n elif model:\n # Modèle spécifique — trouver quel provider le sert\n prov, model_name = await self._find_model(model)\n params.model = model_name\n elif provider:\n # Provider spécifique — laisser le sélecteur choisir le modèle\n selection = await self._selector.select(\n tier,\n provider=provider,\n prefer_local=not self.airgap,\n )\n prov = self._providers[selection.provider]\n params.model = selection.model\n else:\n # Sélection automatique complète\n selection = await self._selector.select(\n tier,\n prefer_local=not self.airgap,\n prefer_uncensored=self.prefer_uncensored,\n )\n prov = self._providers[selection.provider]\n params.model = selection.model\n\n logger.info(\n \"ai_generate\", tier=tier.value, model=params.model, provider=prov.provider_type.value,\n )\n\n try:\n return await asyncio.wait_for(prov.generate(params), timeout=180.0)\n except TimeoutError:\n logger.exception(\n \"ai_generate_timeout\", provider=prov.provider_type.value, model=params.model,\n )\n msg = f\"Provider '{prov.provider_type.value}' timed out after 180s\"\n raise RuntimeError(\n msg,\n )\n\n async def stream(\n self,\n prompt: str,\n *,\n tier: ModelTier = ModelTier.MEDIUM,\n system: str | None = None,\n max_tokens: int = 2048,\n temperature: float = 0.7,\n provider: ProviderType | None = None,\n model: str | None = None,\n ) -> AsyncIterator[str]:\n \"\"\"Streaming — identique à generate() mais yield les chunks.\"\"\"\n if not self._initialized:\n await self.initialize()\n\n params = GenerateParams(\n prompt=prompt,\n system=system,\n max_tokens=max_tokens,\n temperature=temperature,\n )\n\n if model and provider:\n prov = self._providers.get(provider)\n if not prov:\n msg = f\"Provider {provider.value} not available\"\n raise RuntimeError(msg)\n params.model = model\n elif model:\n prov, model_name = await self._find_model(model)\n params.model = model_name\n elif provider:\n selection = await self._selector.select(\n tier,\n provider=provider,\n prefer_local=not self.airgap,\n )\n prov = self._providers[selection.provider]\n params.model = selection.model\n else:\n selection = await self._selector.select(\n tier,\n prefer_local=not self.airgap,\n prefer_uncensored=self.prefer_uncensored,\n )\n prov = self._providers[selection.provider]\n params.model = selection.model\n\n async for chunk in prov.stream(params):\n yield chunk\n\n async def _find_model(self, model_name: str) -> tuple[BaseProvider, str]:\n \"\"\"Trouve quel provider sert un modèle donné.\"\"\"\n for pt, prov in self._providers.items():\n try:\n models = await prov.list_models()\n for m in models:\n if m.name == model_name:\n return prov, model_name\n except (OSError, RuntimeError, ValueError) as e:\n logger.warning(\"provider_list_models_failed\", provider=pt.value, error=str(e))\n continue\n msg = f\"Model '{model_name}' not found in any provider\"\n raise RuntimeError(msg)\n\n # ── Status & Management ─────────────────────────────────────\n\n async def get_status(self) -> dict:\n \"\"\"Retourne l'état complet du moteur IA.\"\"\"\n if not self._initialized:\n await self.initialize()\n\n return {\n \"initialized\": self._initialized,\n \"airgap\": self.airgap,\n \"hardware\": {\n \"ram_gb\": self._hw.ram_total_gb if self._hw else 0,\n \"gpu\": self._hw.gpu_name if self._hw else None,\n \"max_local_tier\": self._hw.max_local_tier if self._hw else None,\n },\n \"providers\": [\n {\n \"type\": pt.value,\n \"available\": await prov.health_check(),\n }\n for pt, prov in self._providers.items()\n ],\n \"selector\": self._selector.get_status() if self._selector else None,\n }\n\n async def reload(self) -> dict:\n \"\"\"Réinitialise tous les providers (après changement de config).\"\"\"\n self._providers.clear()\n self._initialized = False\n self._selector = None\n return await self.initialize()\n\n def get_selector(self) -> ModelSelector | None:\n \"\"\"Accès direct au sélecteur pour des requêtes avancées.\"\"\"\n return self._selector\n\n @property\n def hardware(self) -> HardwareProfile | None:\n return self._hw\n\n @property\n def providers(self) -> dict[ProviderType, BaseProvider]:\n return self._providers\n"}}}} |