Merge branch 'main' into feat/custom-llm-providers
9 files · +268 -38
M README.md +1 -0
| @@ -141,6 +141,7 @@ zot --help | |||
| 141 | 141 | | `--model <id>` | Pick the model (see `--list-models`). | | |
| 142 | 142 | | `--api-key <key>` | Override the API key. | | |
| 143 | 143 | | `--base-url <url>` | Override the provider base URL (tests, self-hosted). | | |
| 144 | + | | `--insecure` | Skip TLS certificate verification, only for the explicit `--base-url` endpoint (self-signed local/internal inference servers). Built-in providers, auth, and model discovery keep normal TLS verification. | | |
| 144 | 145 | | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). | | |
| 145 | 146 | | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). | | |
| 146 | 147 | | `--reasoning off\|minimum\|low\|medium\|high\|maximum` | Set thinking level on supported models (default: off). | | |
| @@ -141,6 +141,7 @@ zot --help | |||||
| 141 | | `--model <id>` | Pick the model (see `--list-models`). | | 141 | | `--model <id>` | Pick the model (see `--list-models`). | | ||
| 142 | | `--api-key <key>` | Override the API key. | | 142 | | `--api-key <key>` | Override the API key. | | ||
| 143 | | `--base-url <url>` | Override the provider base URL (tests, self-hosted). | | 143 | | `--base-url <url>` | Override the provider base URL (tests, self-hosted). | | ||
| 144 | + | | `--insecure` | Skip TLS certificate verification, only for the explicit `--base-url` endpoint (self-signed local/internal inference servers). Built-in providers, auth, and model discovery keep normal TLS verification. | | |||
| 144 | | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). | | 145 | | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). | | ||
| 145 | | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). | | 146 | | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). | | ||
| 146 | | `--reasoning off\|minimum\|low\|medium\|high\|maximum` | Set thinking level on supported models (default: off). | | 147 | | `--reasoning off\|minimum\|low\|medium\|high\|maximum` | Set thinking level on supported models (default: off). | | ||
M packages/agent/args.go +6 -0
| @@ -74,6 +74,9 @@ type Args struct { | |||
| 74 | 74 | // skill discovery, including built-ins. | |
| 75 | 75 | WithSkills bool | |
| 76 | 76 | ||
| 77 | + | // InsecureTLS skips TLS verification for custom inference endpoints. | |
| 78 | + | InsecureTLS bool | |
| 79 | + | ||
| 77 | 80 | // NoYolo turns on per-tool confirmation. Before each tool | |
| 78 | 81 | // invocation the TUI prompts the user with the tool name + args | |
| 79 | 82 | // and waits for an explicit yes/no. The user can also pick | |
| @@ -190,6 +193,8 @@ func ParseArgs(in []string) (Args, error) { | |||
| 190 | 193 | case "--with-skills", "--with-skill": | |
| 191 | 194 | // Deprecated no-op: user skills are loaded by default. | |
| 192 | 195 | a.WithSkills = true | |
| 196 | + | case "--insecure": | |
| 197 | + | a.InsecureTLS = true | |
| 193 | 198 | case "--no-yolo": | |
| 194 | 199 | a.NoYolo = true | |
| 195 | 200 | case "--reasoning": | |
| @@ -373,6 +378,7 @@ func PrintHelp(version string) { | |||
| 373 | 378 | row{"--model ID", "model id (see --list-models)"}, | |
| 374 | 379 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | |
| 375 | 380 | row{"--base-url URL", "override provider api base url"}, | |
| 381 | + | row{"--insecure", "skip TLS certificate verification (for self-signed-cert endpoints)"}, | |
| 376 | 382 | row{"--reasoning off|minimum|low|medium|high|maximum", "set thinking level on supported models"}, | |
| 377 | 383 | row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"}, | |
| 378 | 384 | ) | |
| @@ -74,6 +74,9 @@ type Args struct { | |||||
| 74 | // skill discovery, including built-ins. | 74 | // skill discovery, including built-ins. | ||
| 75 | WithSkills bool | 75 | WithSkills bool | ||
| 76 | 76 | ||||
| 77 | + | // InsecureTLS skips TLS verification for custom inference endpoints. | |||
| 78 | + | InsecureTLS bool | |||
| 79 | + | ||||
| 77 | // NoYolo turns on per-tool confirmation. Before each tool | 80 | // NoYolo turns on per-tool confirmation. Before each tool | ||
| 78 | // invocation the TUI prompts the user with the tool name + args | 81 | // invocation the TUI prompts the user with the tool name + args | ||
| 79 | // and waits for an explicit yes/no. The user can also pick | 82 | // and waits for an explicit yes/no. The user can also pick | ||
| @@ -190,6 +193,8 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 190 | case "--with-skills", "--with-skill": | 193 | case "--with-skills", "--with-skill": | ||
| 191 | // Deprecated no-op: user skills are loaded by default. | 194 | // Deprecated no-op: user skills are loaded by default. | ||
| 192 | a.WithSkills = true | 195 | a.WithSkills = true | ||
| 196 | + | case "--insecure": | |||
| 197 | + | a.InsecureTLS = true | |||
| 193 | case "--no-yolo": | 198 | case "--no-yolo": | ||
| 194 | a.NoYolo = true | 199 | a.NoYolo = true | ||
| 195 | case "--reasoning": | 200 | case "--reasoning": | ||
| @@ -373,6 +378,7 @@ func PrintHelp(version string) { | |||||
| 373 | row{"--model ID", "model id (see --list-models)"}, | 378 | row{"--model ID", "model id (see --list-models)"}, | ||
| 374 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | 379 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | ||
| 375 | row{"--base-url URL", "override provider api base url"}, | 380 | row{"--base-url URL", "override provider api base url"}, | ||
| 381 | + | row{"--insecure", "skip TLS certificate verification (for self-signed-cert endpoints)"}, | |||
| 376 | row{"--reasoning off|minimum|low|medium|high|maximum", "set thinking level on supported models"}, | 382 | row{"--reasoning off|minimum|low|medium|high|maximum", "set thinking level on supported models"}, | ||
| 377 | row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"}, | 383 | row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"}, | ||
| 378 | ) | 384 | ) | ||
M packages/agent/build.go +55 -38
| @@ -23,6 +23,7 @@ type Resolved struct { | |||
| 23 | 23 | AuthMethod string // "apikey" | "oauth" | "" (no credential yet) | |
| 24 | 24 | AccountID string // ChatGPT account id (for openai oauth), "" otherwise | |
| 25 | 25 | BaseURL string | |
| 26 | + | InsecureTLS bool | |
| 26 | 27 | CWD string | |
| 27 | 28 | Reasoning string | |
| 28 | 29 | Temperature *float32 | |
| @@ -455,6 +456,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 455 | 456 | model = fm.ID | |
| 456 | 457 | } | |
| 457 | 458 | ||
| 459 | + | explicitBaseURL := args.BaseURL != "" | |
| 460 | + | ||
| 458 | 461 | // If the model defines a base URL (e.g. local ollama) and the | |
| 459 | 462 | // user didn't pass --base-url, use the model's URL. For ollama, | |
| 460 | 463 | // keep http://localhost:11434 as a fallback only after the model | |
| @@ -466,6 +469,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 466 | 469 | args.BaseURL = "http://localhost:11434" | |
| 467 | 470 | } | |
| 468 | 471 | ||
| 472 | + | // Insecure TLS is intentionally scoped to explicit custom endpoints. | |
| 473 | + | // Built-in provider base URLs, auth calls, and model discovery keep normal | |
| 474 | + | // certificate verification even when --insecure is present. | |
| 475 | + | insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL | |
| 476 | + | ||
| 469 | 477 | // If the model has a base URL, credentials are optional (local | |
| 470 | 478 | // models like ollama don't need real API keys). | |
| 471 | 479 | if resolvedModel.BaseURL != "" && credErr != nil { | |
| @@ -553,6 +561,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 553 | 561 | AuthMethod: method, | |
| 554 | 562 | AccountID: accountID, | |
| 555 | 563 | BaseURL: args.BaseURL, | |
| 564 | + | InsecureTLS: insecureTLS, | |
| 556 | 565 | CWD: args.CWD, | |
| 557 | 566 | Reasoning: reasoning, | |
| 558 | 567 | Temperature: temperature, | |
| @@ -680,83 +689,84 @@ func (r Resolved) NewClient() provider.Client { | |||
| 680 | 689 | if !r.HasCredential() { | |
| 681 | 690 | panic("NewClient called without credential; check HasCredential first") | |
| 682 | 691 | } | |
| 692 | + | wrap := r.withHTTPClient | |
| 683 | 693 | switch r.Provider { | |
| 684 | 694 | case "ollama": | |
| 685 | - | return provider.NewOpenAI(r.Credential, r.BaseURL) | |
| 695 | + | return wrap(provider.NewOpenAI(r.Credential, r.BaseURL)) | |
| 686 | 696 | case "kimi": | |
| 687 | 697 | // kimi-coding speaks anthropic-messages on api.kimi.com/coding. | |
| 688 | 698 | // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client. | |
| 689 | - | inner := provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()) | |
| 699 | + | inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())) | |
| 690 | 700 | if r.AuthMethod == "oauth" { | |
| 691 | 701 | return r.wrapWithRefresh(inner) | |
| 692 | 702 | } | |
| 693 | 703 | return inner | |
| 694 | 704 | case "moonshotai": | |
| 695 | - | return provider.NewMoonshot(r.Credential, r.BaseURL) | |
| 705 | + | return wrap(provider.NewMoonshot(r.Credential, r.BaseURL)) | |
| 696 | 706 | case "moonshotai-cn": | |
| 697 | - | return provider.NewMoonshotCN(r.Credential, r.BaseURL) | |
| 707 | + | return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL)) | |
| 698 | 708 | case "deepseek": | |
| 699 | - | return provider.NewDeepSeek(r.Credential, r.BaseURL) | |
| 709 | + | return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL)) | |
| 700 | 710 | case "openai": | |
| 701 | - | return provider.NewOpenAI(r.Credential, r.BaseURL) | |
| 711 | + | return wrap(provider.NewOpenAI(r.Credential, r.BaseURL)) | |
| 702 | 712 | case "openai-codex": | |
| 703 | - | inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL) | |
| 713 | + | inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)) | |
| 704 | 714 | return r.wrapWithRefresh(inner) | |
| 705 | 715 | case "openai-responses": | |
| 706 | 716 | // Public OpenAI Responses API (api.openai.com/v1/responses) via | |
| 707 | 717 | // API key. Separate provider from `openai` (Chat Completions) and | |
| 708 | 718 | // from `openai-codex` (ChatGPT subscription OAuth). | |
| 709 | - | return provider.NewOpenAIResponses(r.Credential, r.BaseURL) | |
| 719 | + | return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL)) | |
| 710 | 720 | case "google": | |
| 711 | - | return provider.NewGemini(r.Credential, r.BaseURL) | |
| 721 | + | return wrap(provider.NewGemini(r.Credential, r.BaseURL)) | |
| 712 | 722 | case "cerebras": | |
| 713 | - | return provider.NewCerebras(r.Credential, r.BaseURL) | |
| 723 | + | return wrap(provider.NewCerebras(r.Credential, r.BaseURL)) | |
| 714 | 724 | case "groq": | |
| 715 | - | return provider.NewGroq(r.Credential, r.BaseURL) | |
| 725 | + | return wrap(provider.NewGroq(r.Credential, r.BaseURL)) | |
| 716 | 726 | case "xai": | |
| 717 | - | return provider.NewXAI(r.Credential, r.BaseURL) | |
| 727 | + | return wrap(provider.NewXAI(r.Credential, r.BaseURL)) | |
| 718 | 728 | case "together": | |
| 719 | - | return provider.NewTogether(r.Credential, r.BaseURL) | |
| 729 | + | return wrap(provider.NewTogether(r.Credential, r.BaseURL)) | |
| 720 | 730 | case "huggingface": | |
| 721 | - | return provider.NewHuggingFace(r.Credential, r.BaseURL) | |
| 731 | + | return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL)) | |
| 722 | 732 | case "openrouter": | |
| 723 | - | return provider.NewOpenRouter(r.Credential, r.BaseURL) | |
| 733 | + | return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL)) | |
| 724 | 734 | case "zai": | |
| 725 | - | return provider.NewZAI(r.Credential, r.BaseURL) | |
| 735 | + | return wrap(provider.NewZAI(r.Credential, r.BaseURL)) | |
| 726 | 736 | case "xiaomi": | |
| 727 | - | return provider.NewXiaomi(r.Credential, r.BaseURL) | |
| 737 | + | return wrap(provider.NewXiaomi(r.Credential, r.BaseURL)) | |
| 728 | 738 | case "xiaomi-token-plan-ams": | |
| 729 | - | return provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL) | |
| 739 | + | return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL)) | |
| 730 | 740 | case "xiaomi-token-plan-cn": | |
| 731 | - | return provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL) | |
| 741 | + | return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL)) | |
| 732 | 742 | case "xiaomi-token-plan-sgp": | |
| 733 | - | return provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL) | |
| 743 | + | return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL)) | |
| 734 | 744 | case "opencode": | |
| 735 | - | return provider.NewOpenCode(r.Credential, r.BaseURL) | |
| 745 | + | return wrap(provider.NewOpenCode(r.Credential, r.BaseURL)) | |
| 736 | 746 | case "opencode-go": | |
| 737 | - | return provider.NewOpenCodeGo(r.Credential, r.BaseURL) | |
| 747 | + | return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL)) | |
| 738 | 748 | case "minimax": | |
| 739 | - | return provider.NewMinimaxAnthropic(r.Credential, r.BaseURL) | |
| 749 | + | return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL)) | |
| 740 | 750 | case "minimax-cn": | |
| 741 | - | return provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL) | |
| 751 | + | return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL)) | |
| 742 | 752 | case "fireworks": | |
| 743 | - | return provider.NewFireworksAnthropic(r.Credential, r.BaseURL) | |
| 753 | + | return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL)) | |
| 744 | 754 | case "vercel-ai-gateway": | |
| 745 | - | return provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL) | |
| 755 | + | return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL)) | |
| 746 | 756 | case "mistral": | |
| 747 | - | return provider.NewMistral(r.Credential, r.BaseURL) | |
| 757 | + | return wrap(provider.NewMistral(r.Credential, r.BaseURL)) | |
| 748 | 758 | case "amazon-bedrock": | |
| 749 | - | return provider.NewBedrock(r.Credential, r.BaseURL) | |
| 759 | + | return wrap(provider.NewBedrock(r.Credential, r.BaseURL)) | |
| 750 | 760 | case "google-vertex": | |
| 751 | - | return provider.NewGoogleVertex(r.Credential, r.BaseURL) | |
| 761 | + | return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL)) | |
| 752 | 762 | case "azure-openai-responses": | |
| 753 | - | return provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL) | |
| 763 | + | return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL)) | |
| 754 | 764 | case "github-copilot": | |
| 755 | - | return provider.NewGithubCopilot(r.Credential, r.BaseURL) | |
| 765 | + | return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL)) | |
| 756 | 766 | case "cloudflare-workers-ai": | |
| 757 | - | return provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL) | |
| 767 | + | return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL)) | |
| 758 | 768 | case "cloudflare-ai-gateway": | |
| 759 | - | return provider.NewCloudflareAIGateway(r.Credential, r.BaseURL) | |
| 769 | + | return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)) | |
| 760 | 770 | default: | |
| 761 | 771 | // Custom providers: choose wire format from the models.json api field. | |
| 762 | 772 | if cfg, ok := provider.CustomProviders()[r.Provider]; ok { | |
| @@ -768,11 +778,18 @@ func (r Resolved) NewClient() provider.Client { | |||
| 768 | 778 | } | |
| 769 | 779 | } | |
| 770 | 780 | if r.AuthMethod == "oauth" { | |
| 771 | - | inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL) | |
| 781 | + | inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL)) | |
| 772 | 782 | return r.wrapWithRefresh(inner) | |
| 773 | 783 | } | |
| 774 | - | return provider.NewAnthropic(r.Credential, r.BaseURL) | |
| 784 | + | return wrap(provider.NewAnthropic(r.Credential, r.BaseURL)) | |
| 785 | + | } | |
| 786 | + | } | |
| 787 | + | ||
| 788 | + | func (r Resolved) withHTTPClient(c provider.Client) provider.Client { | |
| 789 | + | if !r.InsecureTLS { | |
| 790 | + | return c | |
| 775 | 791 | } | |
| 792 | + | return provider.WithHTTPClient(c, provider.NewHTTPClient(true)) | |
| 776 | 793 | } | |
| 777 | 794 | ||
| 778 | 795 | // wrapWithRefresh wraps an OAuth client so the access token is | |
| @@ -798,12 +815,12 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client { | |||
| 798 | 815 | factory := func(token string) provider.Client { | |
| 799 | 816 | switch provName { | |
| 800 | 817 | case "openai-codex": | |
| 801 | - | return provider.NewOpenAICodex(token, accountID, baseURL) | |
| 818 | + | return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL)) | |
| 802 | 819 | case "kimi": | |
| 803 | 820 | // anthropic-messages on api.kimi.com/coding. | |
| 804 | - | return provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()) | |
| 821 | + | return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders())) | |
| 805 | 822 | default: | |
| 806 | - | return provider.NewAnthropicOAuth(token, baseURL) | |
| 823 | + | return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL)) | |
| 807 | 824 | } | |
| 808 | 825 | } | |
| 809 | 826 | ||
| @@ -23,6 +23,7 @@ type Resolved struct { | |||||
| 23 | AuthMethod string // "apikey" | "oauth" | "" (no credential yet) | 23 | AuthMethod string // "apikey" | "oauth" | "" (no credential yet) | ||
| 24 | AccountID string // ChatGPT account id (for openai oauth), "" otherwise | 24 | AccountID string // ChatGPT account id (for openai oauth), "" otherwise | ||
| 25 | BaseURL string | 25 | BaseURL string | ||
| 26 | + | InsecureTLS bool | |||
| 26 | CWD string | 27 | CWD string | ||
| 27 | Reasoning string | 28 | Reasoning string | ||
| 28 | Temperature *float32 | 29 | Temperature *float32 | ||
| @@ -455,6 +456,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 455 | model = fm.ID | 456 | model = fm.ID | ||
| 456 | } | 457 | } | ||
| 457 | 458 | ||||
| 459 | + | explicitBaseURL := args.BaseURL != "" | |||
| 460 | + | ||||
| 458 | // If the model defines a base URL (e.g. local ollama) and the | 461 | // If the model defines a base URL (e.g. local ollama) and the | ||
| 459 | // user didn't pass --base-url, use the model's URL. For ollama, | 462 | // user didn't pass --base-url, use the model's URL. For ollama, | ||
| 460 | // keep http://localhost:11434 as a fallback only after the model | 463 | // keep http://localhost:11434 as a fallback only after the model | ||
| @@ -466,6 +469,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 466 | args.BaseURL = "http://localhost:11434" | 469 | args.BaseURL = "http://localhost:11434" | ||
| 467 | } | 470 | } | ||
| 468 | 471 | ||||
| 472 | + | // Insecure TLS is intentionally scoped to explicit custom endpoints. | |||
| 473 | + | // Built-in provider base URLs, auth calls, and model discovery keep normal | |||
| 474 | + | // certificate verification even when --insecure is present. | |||
| 475 | + | insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL | |||
| 476 | + | ||||
| 469 | // If the model has a base URL, credentials are optional (local | 477 | // If the model has a base URL, credentials are optional (local | ||
| 470 | // models like ollama don't need real API keys). | 478 | // models like ollama don't need real API keys). | ||
| 471 | if resolvedModel.BaseURL != "" && credErr != nil { | 479 | if resolvedModel.BaseURL != "" && credErr != nil { | ||
| @@ -553,6 +561,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 553 | AuthMethod: method, | 561 | AuthMethod: method, | ||
| 554 | AccountID: accountID, | 562 | AccountID: accountID, | ||
| 555 | BaseURL: args.BaseURL, | 563 | BaseURL: args.BaseURL, | ||
| 564 | + | InsecureTLS: insecureTLS, | |||
| 556 | CWD: args.CWD, | 565 | CWD: args.CWD, | ||
| 557 | Reasoning: reasoning, | 566 | Reasoning: reasoning, | ||
| 558 | Temperature: temperature, | 567 | Temperature: temperature, | ||
| @@ -680,83 +689,84 @@ func (r Resolved) NewClient() provider.Client { | |||||
| 680 | if !r.HasCredential() { | 689 | if !r.HasCredential() { | ||
| 681 | panic("NewClient called without credential; check HasCredential first") | 690 | panic("NewClient called without credential; check HasCredential first") | ||
| 682 | } | 691 | } | ||
| 692 | + | wrap := r.withHTTPClient | |||
| 683 | switch r.Provider { | 693 | switch r.Provider { | ||
| 684 | case "ollama": | 694 | case "ollama": | ||
| 685 | - | return provider.NewOpenAI(r.Credential, r.BaseURL) | 695 | + | return wrap(provider.NewOpenAI(r.Credential, r.BaseURL)) |
| 686 | case "kimi": | 696 | case "kimi": | ||
| 687 | // kimi-coding speaks anthropic-messages on api.kimi.com/coding. | 697 | // kimi-coding speaks anthropic-messages on api.kimi.com/coding. | ||
| 688 | // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client. | 698 | // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client. | ||
| 689 | - | inner := provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()) | 699 | + | inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())) |
| 690 | if r.AuthMethod == "oauth" { | 700 | if r.AuthMethod == "oauth" { | ||
| 691 | return r.wrapWithRefresh(inner) | 701 | return r.wrapWithRefresh(inner) | ||
| 692 | } | 702 | } | ||
| 693 | return inner | 703 | return inner | ||
| 694 | case "moonshotai": | 704 | case "moonshotai": | ||
| 695 | - | return provider.NewMoonshot(r.Credential, r.BaseURL) | 705 | + | return wrap(provider.NewMoonshot(r.Credential, r.BaseURL)) |
| 696 | case "moonshotai-cn": | 706 | case "moonshotai-cn": | ||
| 697 | - | return provider.NewMoonshotCN(r.Credential, r.BaseURL) | 707 | + | return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL)) |
| 698 | case "deepseek": | 708 | case "deepseek": | ||
| 699 | - | return provider.NewDeepSeek(r.Credential, r.BaseURL) | 709 | + | return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL)) |
| 700 | case "openai": | 710 | case "openai": | ||
| 701 | - | return provider.NewOpenAI(r.Credential, r.BaseURL) | 711 | + | return wrap(provider.NewOpenAI(r.Credential, r.BaseURL)) |
| 702 | case "openai-codex": | 712 | case "openai-codex": | ||
| 703 | - | inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL) | 713 | + | inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)) |
| 704 | return r.wrapWithRefresh(inner) | 714 | return r.wrapWithRefresh(inner) | ||
| 705 | case "openai-responses": | 715 | case "openai-responses": | ||
| 706 | // Public OpenAI Responses API (api.openai.com/v1/responses) via | 716 | // Public OpenAI Responses API (api.openai.com/v1/responses) via | ||
| 707 | // API key. Separate provider from `openai` (Chat Completions) and | 717 | // API key. Separate provider from `openai` (Chat Completions) and | ||
| 708 | // from `openai-codex` (ChatGPT subscription OAuth). | 718 | // from `openai-codex` (ChatGPT subscription OAuth). | ||
| 709 | - | return provider.NewOpenAIResponses(r.Credential, r.BaseURL) | 719 | + | return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL)) |
| 710 | case "google": | 720 | case "google": | ||
| 711 | - | return provider.NewGemini(r.Credential, r.BaseURL) | 721 | + | return wrap(provider.NewGemini(r.Credential, r.BaseURL)) |
| 712 | case "cerebras": | 722 | case "cerebras": | ||
| 713 | - | return provider.NewCerebras(r.Credential, r.BaseURL) | 723 | + | return wrap(provider.NewCerebras(r.Credential, r.BaseURL)) |
| 714 | case "groq": | 724 | case "groq": | ||
| 715 | - | return provider.NewGroq(r.Credential, r.BaseURL) | 725 | + | return wrap(provider.NewGroq(r.Credential, r.BaseURL)) |
| 716 | case "xai": | 726 | case "xai": | ||
| 717 | - | return provider.NewXAI(r.Credential, r.BaseURL) | 727 | + | return wrap(provider.NewXAI(r.Credential, r.BaseURL)) |
| 718 | case "together": | 728 | case "together": | ||
| 719 | - | return provider.NewTogether(r.Credential, r.BaseURL) | 729 | + | return wrap(provider.NewTogether(r.Credential, r.BaseURL)) |
| 720 | case "huggingface": | 730 | case "huggingface": | ||
| 721 | - | return provider.NewHuggingFace(r.Credential, r.BaseURL) | 731 | + | return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL)) |
| 722 | case "openrouter": | 732 | case "openrouter": | ||
| 723 | - | return provider.NewOpenRouter(r.Credential, r.BaseURL) | 733 | + | return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL)) |
| 724 | case "zai": | 734 | case "zai": | ||
| 725 | - | return provider.NewZAI(r.Credential, r.BaseURL) | 735 | + | return wrap(provider.NewZAI(r.Credential, r.BaseURL)) |
| 726 | case "xiaomi": | 736 | case "xiaomi": | ||
| 727 | - | return provider.NewXiaomi(r.Credential, r.BaseURL) | 737 | + | return wrap(provider.NewXiaomi(r.Credential, r.BaseURL)) |
| 728 | case "xiaomi-token-plan-ams": | 738 | case "xiaomi-token-plan-ams": | ||
| 729 | - | return provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL) | 739 | + | return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL)) |
| 730 | case "xiaomi-token-plan-cn": | 740 | case "xiaomi-token-plan-cn": | ||
| 731 | - | return provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL) | 741 | + | return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL)) |
| 732 | case "xiaomi-token-plan-sgp": | 742 | case "xiaomi-token-plan-sgp": | ||
| 733 | - | return provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL) | 743 | + | return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL)) |
| 734 | case "opencode": | 744 | case "opencode": | ||
| 735 | - | return provider.NewOpenCode(r.Credential, r.BaseURL) | 745 | + | return wrap(provider.NewOpenCode(r.Credential, r.BaseURL)) |
| 736 | case "opencode-go": | 746 | case "opencode-go": | ||
| 737 | - | return provider.NewOpenCodeGo(r.Credential, r.BaseURL) | 747 | + | return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL)) |
| 738 | case "minimax": | 748 | case "minimax": | ||
| 739 | - | return provider.NewMinimaxAnthropic(r.Credential, r.BaseURL) | 749 | + | return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL)) |
| 740 | case "minimax-cn": | 750 | case "minimax-cn": | ||
| 741 | - | return provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL) | 751 | + | return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL)) |
| 742 | case "fireworks": | 752 | case "fireworks": | ||
| 743 | - | return provider.NewFireworksAnthropic(r.Credential, r.BaseURL) | 753 | + | return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL)) |
| 744 | case "vercel-ai-gateway": | 754 | case "vercel-ai-gateway": | ||
| 745 | - | return provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL) | 755 | + | return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL)) |
| 746 | case "mistral": | 756 | case "mistral": | ||
| 747 | - | return provider.NewMistral(r.Credential, r.BaseURL) | 757 | + | return wrap(provider.NewMistral(r.Credential, r.BaseURL)) |
| 748 | case "amazon-bedrock": | 758 | case "amazon-bedrock": | ||
| 749 | - | return provider.NewBedrock(r.Credential, r.BaseURL) | 759 | + | return wrap(provider.NewBedrock(r.Credential, r.BaseURL)) |
| 750 | case "google-vertex": | 760 | case "google-vertex": | ||
| 751 | - | return provider.NewGoogleVertex(r.Credential, r.BaseURL) | 761 | + | return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL)) |
| 752 | case "azure-openai-responses": | 762 | case "azure-openai-responses": | ||
| 753 | - | return provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL) | 763 | + | return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL)) |
| 754 | case "github-copilot": | 764 | case "github-copilot": | ||
| 755 | - | return provider.NewGithubCopilot(r.Credential, r.BaseURL) | 765 | + | return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL)) |
| 756 | case "cloudflare-workers-ai": | 766 | case "cloudflare-workers-ai": | ||
| 757 | - | return provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL) | 767 | + | return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL)) |
| 758 | case "cloudflare-ai-gateway": | 768 | case "cloudflare-ai-gateway": | ||
| 759 | - | return provider.NewCloudflareAIGateway(r.Credential, r.BaseURL) | 769 | + | return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)) |
| 760 | default: | 770 | default: | ||
| 761 | // Custom providers: choose wire format from the models.json api field. | 771 | // Custom providers: choose wire format from the models.json api field. | ||
| 762 | if cfg, ok := provider.CustomProviders()[r.Provider]; ok { | 772 | if cfg, ok := provider.CustomProviders()[r.Provider]; ok { | ||
| @@ -768,11 +778,18 @@ func (r Resolved) NewClient() provider.Client { | |||||
| 768 | } | 778 | } | ||
| 769 | } | 779 | } | ||
| 770 | if r.AuthMethod == "oauth" { | 780 | if r.AuthMethod == "oauth" { | ||
| 771 | - | inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL) | 781 | + | inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL)) |
| 772 | return r.wrapWithRefresh(inner) | 782 | return r.wrapWithRefresh(inner) | ||
| 773 | } | 783 | } | ||
| 774 | - | return provider.NewAnthropic(r.Credential, r.BaseURL) | 784 | + | return wrap(provider.NewAnthropic(r.Credential, r.BaseURL)) |
| 785 | + | } | |||
| 786 | + | } | |||
| 787 | + | ||||
| 788 | + | func (r Resolved) withHTTPClient(c provider.Client) provider.Client { | |||
| 789 | + | if !r.InsecureTLS { | |||
| 790 | + | return c | |||
| 775 | } | 791 | } | ||
| 792 | + | return provider.WithHTTPClient(c, provider.NewHTTPClient(true)) | |||
| 776 | } | 793 | } | ||
| 777 | 794 | ||||
| 778 | // wrapWithRefresh wraps an OAuth client so the access token is | 795 | // wrapWithRefresh wraps an OAuth client so the access token is | ||
| @@ -798,12 +815,12 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client { | |||||
| 798 | factory := func(token string) provider.Client { | 815 | factory := func(token string) provider.Client { | ||
| 799 | switch provName { | 816 | switch provName { | ||
| 800 | case "openai-codex": | 817 | case "openai-codex": | ||
| 801 | - | return provider.NewOpenAICodex(token, accountID, baseURL) | 818 | + | return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL)) |
| 802 | case "kimi": | 819 | case "kimi": | ||
| 803 | // anthropic-messages on api.kimi.com/coding. | 820 | // anthropic-messages on api.kimi.com/coding. | ||
| 804 | - | return provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()) | 821 | + | return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders())) |
| 805 | default: | 822 | default: | ||
| 806 | - | return provider.NewAnthropicOAuth(token, baseURL) | 823 | + | return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL)) |
| 807 | } | 824 | } | ||
| 808 | } | 825 | } | ||
| 809 | 826 | ||||
M packages/agent/build_test.go +67 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package agent | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "net/http" | |
| 4 | 5 | "os" | |
| 5 | 6 | "path/filepath" | |
| 6 | 7 | "strings" | |
| @@ -211,3 +212,69 @@ func TestCanonicalProviderAliasesAreKnown(t *testing.T) { | |||
| 211 | 212 | } | |
| 212 | 213 | } | |
| 213 | 214 | } | |
| 215 | + | ||
| 216 | + | func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) { | |
| 217 | + | orig := http.DefaultTransport | |
| 218 | + | t.Cleanup(func() { http.DefaultTransport = orig }) | |
| 219 | + | ||
| 220 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 221 | + | t.Setenv("OPENAI_API_KEY", "test-key") | |
| 222 | + | ||
| 223 | + | resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false) | |
| 224 | + | if err != nil { | |
| 225 | + | t.Fatalf("Resolve: %v", err) | |
| 226 | + | } | |
| 227 | + | if resolved.InsecureTLS { | |
| 228 | + | t.Fatal("InsecureTLS must not be set for built-in provider base URLs") | |
| 229 | + | } | |
| 230 | + | assertDefaultTransportStillSecure(t) | |
| 231 | + | ||
| 232 | + | resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false) | |
| 233 | + | if err != nil { | |
| 234 | + | t.Fatalf("Resolve: %v", err) | |
| 235 | + | } | |
| 236 | + | if !resolved.InsecureTLS { | |
| 237 | + | t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url") | |
| 238 | + | } | |
| 239 | + | assertDefaultTransportStillSecure(t) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) { | |
| 243 | + | orig := http.DefaultTransport | |
| 244 | + | t.Cleanup(func() { http.DefaultTransport = orig }) | |
| 245 | + | ||
| 246 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 247 | + | t.Setenv("OPENAI_API_KEY", "test-key") | |
| 248 | + | if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil { | |
| 249 | + | t.Fatal(err) | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | resolved, err := Resolve(Args{Provider: "openai"}, false) | |
| 253 | + | if err != nil { | |
| 254 | + | t.Fatalf("Resolve: %v", err) | |
| 255 | + | } | |
| 256 | + | if resolved.InsecureTLS { | |
| 257 | + | t.Fatal("InsecureTLS must not be set without a custom base URL") | |
| 258 | + | } | |
| 259 | + | assertDefaultTransportStillSecure(t) | |
| 260 | + | ||
| 261 | + | resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false) | |
| 262 | + | if err != nil { | |
| 263 | + | t.Fatalf("Resolve: %v", err) | |
| 264 | + | } | |
| 265 | + | if !resolved.InsecureTLS { | |
| 266 | + | t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided") | |
| 267 | + | } | |
| 268 | + | assertDefaultTransportStillSecure(t) | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func assertDefaultTransportStillSecure(t *testing.T) { | |
| 272 | + | t.Helper() | |
| 273 | + | tr, ok := http.DefaultTransport.(*http.Transport) | |
| 274 | + | if !ok { | |
| 275 | + | return | |
| 276 | + | } | |
| 277 | + | if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify { | |
| 278 | + | t.Fatal("http.DefaultTransport must not be made insecure") | |
| 279 | + | } | |
| 280 | + | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package agent | 1 | package agent | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "net/http" | |||
| 4 | "os" | 5 | "os" | ||
| 5 | "path/filepath" | 6 | "path/filepath" | ||
| 6 | "strings" | 7 | "strings" | ||
| @@ -211,3 +212,69 @@ func TestCanonicalProviderAliasesAreKnown(t *testing.T) { | |||||
| 211 | } | 212 | } | ||
| 212 | } | 213 | } | ||
| 213 | } | 214 | } | ||
| 215 | + | ||||
| 216 | + | func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) { | |||
| 217 | + | orig := http.DefaultTransport | |||
| 218 | + | t.Cleanup(func() { http.DefaultTransport = orig }) | |||
| 219 | + | ||||
| 220 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 221 | + | t.Setenv("OPENAI_API_KEY", "test-key") | |||
| 222 | + | ||||
| 223 | + | resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false) | |||
| 224 | + | if err != nil { | |||
| 225 | + | t.Fatalf("Resolve: %v", err) | |||
| 226 | + | } | |||
| 227 | + | if resolved.InsecureTLS { | |||
| 228 | + | t.Fatal("InsecureTLS must not be set for built-in provider base URLs") | |||
| 229 | + | } | |||
| 230 | + | assertDefaultTransportStillSecure(t) | |||
| 231 | + | ||||
| 232 | + | resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false) | |||
| 233 | + | if err != nil { | |||
| 234 | + | t.Fatalf("Resolve: %v", err) | |||
| 235 | + | } | |||
| 236 | + | if !resolved.InsecureTLS { | |||
| 237 | + | t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url") | |||
| 238 | + | } | |||
| 239 | + | assertDefaultTransportStillSecure(t) | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) { | |||
| 243 | + | orig := http.DefaultTransport | |||
| 244 | + | t.Cleanup(func() { http.DefaultTransport = orig }) | |||
| 245 | + | ||||
| 246 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 247 | + | t.Setenv("OPENAI_API_KEY", "test-key") | |||
| 248 | + | if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil { | |||
| 249 | + | t.Fatal(err) | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | resolved, err := Resolve(Args{Provider: "openai"}, false) | |||
| 253 | + | if err != nil { | |||
| 254 | + | t.Fatalf("Resolve: %v", err) | |||
| 255 | + | } | |||
| 256 | + | if resolved.InsecureTLS { | |||
| 257 | + | t.Fatal("InsecureTLS must not be set without a custom base URL") | |||
| 258 | + | } | |||
| 259 | + | assertDefaultTransportStillSecure(t) | |||
| 260 | + | ||||
| 261 | + | resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false) | |||
| 262 | + | if err != nil { | |||
| 263 | + | t.Fatalf("Resolve: %v", err) | |||
| 264 | + | } | |||
| 265 | + | if !resolved.InsecureTLS { | |||
| 266 | + | t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided") | |||
| 267 | + | } | |||
| 268 | + | assertDefaultTransportStillSecure(t) | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func assertDefaultTransportStillSecure(t *testing.T) { | |||
| 272 | + | t.Helper() | |||
| 273 | + | tr, ok := http.DefaultTransport.(*http.Transport) | |||
| 274 | + | if !ok { | |||
| 275 | + | return | |||
| 276 | + | } | |||
| 277 | + | if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify { | |||
| 278 | + | t.Fatal("http.DefaultTransport must not be made insecure") | |||
| 279 | + | } | |||
| 280 | + | } | |||
M packages/agent/config.go +3 -0
| @@ -46,6 +46,9 @@ type Config struct { | |||
| 46 | 46 | // which is on; false shows ignored entries. Toggle from /settings. | |
| 47 | 47 | RespectGitignore *bool `json:"respect_gitignore,omitempty"` | |
| 48 | 48 | ||
| 49 | + | // Insecure skips TLS verification for custom inference endpoints. | |
| 50 | + | Insecure bool `json:"insecure,omitempty"` | |
| 51 | + | ||
| 49 | 52 | // LastChangelogShown is the version whose release-notes | |
| 50 | 53 | // dialog the user has already seen. When the running binary's | |
| 51 | 54 | // version differs, the next interactive run shows the | |
| @@ -46,6 +46,9 @@ type Config struct { | |||||
| 46 | // which is on; false shows ignored entries. Toggle from /settings. | 46 | // which is on; false shows ignored entries. Toggle from /settings. | ||
| 47 | RespectGitignore *bool `json:"respect_gitignore,omitempty"` | 47 | RespectGitignore *bool `json:"respect_gitignore,omitempty"` | ||
| 48 | 48 | ||||
| 49 | + | // Insecure skips TLS verification for custom inference endpoints. | |||
| 50 | + | Insecure bool `json:"insecure,omitempty"` | |||
| 51 | + | ||||
| 49 | // LastChangelogShown is the version whose release-notes | 52 | // LastChangelogShown is the version whose release-notes | ||
| 50 | // dialog the user has already seen. When the running binary's | 53 | // dialog the user has already seen. When the running binary's | ||
| 51 | // version differs, the next interactive run shows the | 54 | // version differs, the next interactive run shows the | ||
A packages/provider/httpclient.go +50 -0
| @@ -0,0 +1,50 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/tls" | |
| 5 | + | "net/http" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // NewHTTPClient returns a provider HTTP client. When insecureTLS is true, | |
| 9 | + | // only this client skips TLS certificate verification. The process-wide | |
| 10 | + | // default transport is left untouched so auth, discovery, and other providers | |
| 11 | + | // keep normal certificate validation. | |
| 12 | + | func NewHTTPClient(insecureTLS bool) *http.Client { | |
| 13 | + | if !insecureTLS { | |
| 14 | + | return &http.Client{Timeout: 0} | |
| 15 | + | } | |
| 16 | + | tr, ok := http.DefaultTransport.(*http.Transport) | |
| 17 | + | if ok { | |
| 18 | + | tr = tr.Clone() | |
| 19 | + | } else { | |
| 20 | + | tr = &http.Transport{} | |
| 21 | + | } | |
| 22 | + | if tr.TLSClientConfig != nil { | |
| 23 | + | tr.TLSClientConfig = tr.TLSClientConfig.Clone() | |
| 24 | + | } else { | |
| 25 | + | tr.TLSClientConfig = &tls.Config{} | |
| 26 | + | } | |
| 27 | + | tr.TLSClientConfig.InsecureSkipVerify = true //nolint:gosec | |
| 28 | + | return &http.Client{Timeout: 0, Transport: tr} | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // WithHTTPClient scopes an HTTP client to a concrete provider client. | |
| 32 | + | // Unsupported clients are returned unchanged. | |
| 33 | + | func WithHTTPClient(c Client, httpClient *http.Client) Client { | |
| 34 | + | if httpClient == nil { | |
| 35 | + | return c | |
| 36 | + | } | |
| 37 | + | switch v := c.(type) { | |
| 38 | + | case *openaiClient: | |
| 39 | + | v.http = httpClient | |
| 40 | + | case *anthropicClient: | |
| 41 | + | v.http = httpClient | |
| 42 | + | case *geminiClient: | |
| 43 | + | v.http = httpClient | |
| 44 | + | case *bedrockClient: | |
| 45 | + | v.http = httpClient | |
| 46 | + | case *renamedClient: | |
| 47 | + | v.inner = WithHTTPClient(v.inner, httpClient) | |
| 48 | + | } | |
| 49 | + | return c | |
| 50 | + | } | |
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/tls" | |||
| 5 | + | "net/http" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // NewHTTPClient returns a provider HTTP client. When insecureTLS is true, | |||
| 9 | + | // only this client skips TLS certificate verification. The process-wide | |||
| 10 | + | // default transport is left untouched so auth, discovery, and other providers | |||
| 11 | + | // keep normal certificate validation. | |||
| 12 | + | func NewHTTPClient(insecureTLS bool) *http.Client { | |||
| 13 | + | if !insecureTLS { | |||
| 14 | + | return &http.Client{Timeout: 0} | |||
| 15 | + | } | |||
| 16 | + | tr, ok := http.DefaultTransport.(*http.Transport) | |||
| 17 | + | if ok { | |||
| 18 | + | tr = tr.Clone() | |||
| 19 | + | } else { | |||
| 20 | + | tr = &http.Transport{} | |||
| 21 | + | } | |||
| 22 | + | if tr.TLSClientConfig != nil { | |||
| 23 | + | tr.TLSClientConfig = tr.TLSClientConfig.Clone() | |||
| 24 | + | } else { | |||
| 25 | + | tr.TLSClientConfig = &tls.Config{} | |||
| 26 | + | } | |||
| 27 | + | tr.TLSClientConfig.InsecureSkipVerify = true //nolint:gosec | |||
| 28 | + | return &http.Client{Timeout: 0, Transport: tr} | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // WithHTTPClient scopes an HTTP client to a concrete provider client. | |||
| 32 | + | // Unsupported clients are returned unchanged. | |||
| 33 | + | func WithHTTPClient(c Client, httpClient *http.Client) Client { | |||
| 34 | + | if httpClient == nil { | |||
| 35 | + | return c | |||
| 36 | + | } | |||
| 37 | + | switch v := c.(type) { | |||
| 38 | + | case *openaiClient: | |||
| 39 | + | v.http = httpClient | |||
| 40 | + | case *anthropicClient: | |||
| 41 | + | v.http = httpClient | |||
| 42 | + | case *geminiClient: | |||
| 43 | + | v.http = httpClient | |||
| 44 | + | case *bedrockClient: | |||
| 45 | + | v.http = httpClient | |||
| 46 | + | case *renamedClient: | |||
| 47 | + | v.inner = WithHTTPClient(v.inner, httpClient) | |||
| 48 | + | } | |||
| 49 | + | return c | |||
| 50 | + | } | |||
A packages/provider/httpclient_test.go +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net/http" | |
| 5 | + | "net/http/httptest" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestNewHTTPClientDoesNotChangeDefaultTransport(t *testing.T) { | |
| 10 | + | orig := http.DefaultTransport | |
| 11 | + | t.Cleanup(func() { http.DefaultTransport = orig }) | |
| 12 | + | ||
| 13 | + | client := NewHTTPClient(true) | |
| 14 | + | if http.DefaultTransport != orig { | |
| 15 | + | t.Fatal("NewHTTPClient must not mutate http.DefaultTransport") | |
| 16 | + | } | |
| 17 | + | tr, ok := client.Transport.(*http.Transport) | |
| 18 | + | if !ok { | |
| 19 | + | t.Fatalf("expected *http.Transport, got %T", client.Transport) | |
| 20 | + | } | |
| 21 | + | if tr.TLSClientConfig == nil || !tr.TLSClientConfig.InsecureSkipVerify { | |
| 22 | + | t.Fatal("expected scoped InsecureSkipVerify=true in TLS config") | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestScopedInsecureClientReachesTLSServer(t *testing.T) { | |
| 27 | + | srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 28 | + | w.WriteHeader(http.StatusOK) | |
| 29 | + | })) | |
| 30 | + | defer srv.Close() | |
| 31 | + | ||
| 32 | + | orig := http.DefaultTransport | |
| 33 | + | t.Cleanup(func() { http.DefaultTransport = orig }) | |
| 34 | + | ||
| 35 | + | secureClient := NewHTTPClient(false) | |
| 36 | + | if _, err := secureClient.Get(srv.URL); err == nil { | |
| 37 | + | t.Fatal("expected TLS error with secure client, got nil") | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | insecureClient := NewHTTPClient(true) | |
| 41 | + | resp, err := insecureClient.Get(srv.URL) | |
| 42 | + | if err != nil { | |
| 43 | + | t.Fatalf("request failed with scoped insecure client: %v", err) | |
| 44 | + | } | |
| 45 | + | resp.Body.Close() | |
| 46 | + | if resp.StatusCode != http.StatusOK { | |
| 47 | + | t.Fatalf("status=%d", resp.StatusCode) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | defaultClient := &http.Client{} | |
| 51 | + | if _, err := defaultClient.Get(srv.URL); err == nil { | |
| 52 | + | t.Fatal("default client must still reject self-signed TLS") | |
| 53 | + | } | |
| 54 | + | } | |
| @@ -0,0 +1,54 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/http" | |||
| 5 | + | "net/http/httptest" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestNewHTTPClientDoesNotChangeDefaultTransport(t *testing.T) { | |||
| 10 | + | orig := http.DefaultTransport | |||
| 11 | + | t.Cleanup(func() { http.DefaultTransport = orig }) | |||
| 12 | + | ||||
| 13 | + | client := NewHTTPClient(true) | |||
| 14 | + | if http.DefaultTransport != orig { | |||
| 15 | + | t.Fatal("NewHTTPClient must not mutate http.DefaultTransport") | |||
| 16 | + | } | |||
| 17 | + | tr, ok := client.Transport.(*http.Transport) | |||
| 18 | + | if !ok { | |||
| 19 | + | t.Fatalf("expected *http.Transport, got %T", client.Transport) | |||
| 20 | + | } | |||
| 21 | + | if tr.TLSClientConfig == nil || !tr.TLSClientConfig.InsecureSkipVerify { | |||
| 22 | + | t.Fatal("expected scoped InsecureSkipVerify=true in TLS config") | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestScopedInsecureClientReachesTLSServer(t *testing.T) { | |||
| 27 | + | srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 28 | + | w.WriteHeader(http.StatusOK) | |||
| 29 | + | })) | |||
| 30 | + | defer srv.Close() | |||
| 31 | + | ||||
| 32 | + | orig := http.DefaultTransport | |||
| 33 | + | t.Cleanup(func() { http.DefaultTransport = orig }) | |||
| 34 | + | ||||
| 35 | + | secureClient := NewHTTPClient(false) | |||
| 36 | + | if _, err := secureClient.Get(srv.URL); err == nil { | |||
| 37 | + | t.Fatal("expected TLS error with secure client, got nil") | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | insecureClient := NewHTTPClient(true) | |||
| 41 | + | resp, err := insecureClient.Get(srv.URL) | |||
| 42 | + | if err != nil { | |||
| 43 | + | t.Fatalf("request failed with scoped insecure client: %v", err) | |||
| 44 | + | } | |||
| 45 | + | resp.Body.Close() | |||
| 46 | + | if resp.StatusCode != http.StatusOK { | |||
| 47 | + | t.Fatalf("status=%d", resp.StatusCode) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | defaultClient := &http.Client{} | |||
| 51 | + | if _, err := defaultClient.Get(srv.URL); err == nil { | |||
| 52 | + | t.Fatal("default client must still reject self-signed TLS") | |||
| 53 | + | } | |||
| 54 | + | } | |||
M packages/tui/input.go +13 -0
| @@ -342,9 +342,22 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { | |||
| 342 | 342 | shift := bits&1 != 0 | |
| 343 | 343 | alt := bits&2 != 0 | |
| 344 | 344 | ctrl := bits&4 != 0 | |
| 345 | + | // Kitty keyboard protocol (CSI ... u) reports control keys as their | |
| 346 | + | // codepoints: Esc=27, Enter=13, Tab=9, Backspace=127. Without the | |
| 347 | + | // enhanced-mode handling these arrive as raw bytes; with it enabled | |
| 348 | + | // they come through here, so map them back to their dedicated keys. | |
| 345 | 349 | switch code { | |
| 346 | 350 | case 13: | |
| 347 | 351 | return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl}, true | |
| 352 | + | case 27: | |
| 353 | + | return Key{Kind: KeyEsc, Shift: shift, Alt: alt, Ctrl: ctrl}, true | |
| 354 | + | case 9: | |
| 355 | + | if shift { | |
| 356 | + | return Key{Kind: KeyShiftTab, Alt: alt, Ctrl: ctrl}, true | |
| 357 | + | } | |
| 358 | + | return Key{Kind: KeyTab, Shift: shift, Alt: alt, Ctrl: ctrl}, true | |
| 359 | + | case 127, 8: | |
| 360 | + | return Key{Kind: KeyBackspace, Shift: shift, Alt: alt, Ctrl: ctrl}, true | |
| 348 | 361 | } | |
| 349 | 362 | if ctrl { | |
| 350 | 363 | switch code { | |
| @@ -342,9 +342,22 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { | |||||
| 342 | shift := bits&1 != 0 | 342 | shift := bits&1 != 0 | ||
| 343 | alt := bits&2 != 0 | 343 | alt := bits&2 != 0 | ||
| 344 | ctrl := bits&4 != 0 | 344 | ctrl := bits&4 != 0 | ||
| 345 | + | // Kitty keyboard protocol (CSI ... u) reports control keys as their | |||
| 346 | + | // codepoints: Esc=27, Enter=13, Tab=9, Backspace=127. Without the | |||
| 347 | + | // enhanced-mode handling these arrive as raw bytes; with it enabled | |||
| 348 | + | // they come through here, so map them back to their dedicated keys. | |||
| 345 | switch code { | 349 | switch code { | ||
| 346 | case 13: | 350 | case 13: | ||
| 347 | return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl}, true | 351 | return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl}, true | ||
| 352 | + | case 27: | |||
| 353 | + | return Key{Kind: KeyEsc, Shift: shift, Alt: alt, Ctrl: ctrl}, true | |||
| 354 | + | case 9: | |||
| 355 | + | if shift { | |||
| 356 | + | return Key{Kind: KeyShiftTab, Alt: alt, Ctrl: ctrl}, true | |||
| 357 | + | } | |||
| 358 | + | return Key{Kind: KeyTab, Shift: shift, Alt: alt, Ctrl: ctrl}, true | |||
| 359 | + | case 127, 8: | |||
| 360 | + | return Key{Kind: KeyBackspace, Shift: shift, Alt: alt, Ctrl: ctrl}, true | |||
| 348 | } | 361 | } | ||
| 349 | if ctrl { | 362 | if ctrl { | ||
| 350 | switch code { | 363 | switch code { | ||
M packages/tui/input_test.go +19 -0
| @@ -30,6 +30,25 @@ func TestReaderParsesModifyOtherKeysCtrlC(t *testing.T) { | |||
| 30 | 30 | } | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | + | func TestReaderParsesCSIUEsc(t *testing.T) { | |
| 34 | + | k := readKey(t, "\x1b[27u") | |
| 35 | + | if k.Kind != KeyEsc { | |
| 36 | + | t.Fatalf("Read kind=%v, want esc", k.Kind) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestReaderParsesCSIUTabAndBackspace(t *testing.T) { | |
| 41 | + | if k := readKey(t, "\x1b[9u"); k.Kind != KeyTab { | |
| 42 | + | t.Fatalf("Read kind=%v, want tab", k.Kind) | |
| 43 | + | } | |
| 44 | + | if k := readKey(t, "\x1b[9;2u"); k.Kind != KeyShiftTab { | |
| 45 | + | t.Fatalf("Read kind=%v, want shift-tab", k.Kind) | |
| 46 | + | } | |
| 47 | + | if k := readKey(t, "\x1b[127u"); k.Kind != KeyBackspace { | |
| 48 | + | t.Fatalf("Read kind=%v, want backspace", k.Kind) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 33 | 52 | func TestReaderParsesSGRMouseWheel(t *testing.T) { | |
| 34 | 53 | cases := []struct { | |
| 35 | 54 | seq string | |
| @@ -30,6 +30,25 @@ func TestReaderParsesModifyOtherKeysCtrlC(t *testing.T) { | |||||
| 30 | } | 30 | } | ||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | + | func TestReaderParsesCSIUEsc(t *testing.T) { | |||
| 34 | + | k := readKey(t, "\x1b[27u") | |||
| 35 | + | if k.Kind != KeyEsc { | |||
| 36 | + | t.Fatalf("Read kind=%v, want esc", k.Kind) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestReaderParsesCSIUTabAndBackspace(t *testing.T) { | |||
| 41 | + | if k := readKey(t, "\x1b[9u"); k.Kind != KeyTab { | |||
| 42 | + | t.Fatalf("Read kind=%v, want tab", k.Kind) | |||
| 43 | + | } | |||
| 44 | + | if k := readKey(t, "\x1b[9;2u"); k.Kind != KeyShiftTab { | |||
| 45 | + | t.Fatalf("Read kind=%v, want shift-tab", k.Kind) | |||
| 46 | + | } | |||
| 47 | + | if k := readKey(t, "\x1b[127u"); k.Kind != KeyBackspace { | |||
| 48 | + | t.Fatalf("Read kind=%v, want backspace", k.Kind) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 33 | func TestReaderParsesSGRMouseWheel(t *testing.T) { | 52 | func TestReaderParsesSGRMouseWheel(t *testing.T) { | ||
| 34 | cases := []struct { | 53 | cases := []struct { | ||
| 35 | seq string | 54 | seq string | ||