Merge branch 'main' into feat/custom-llm-providers

This commit is contained in:
Patric Eckhart 2026-06-16 19:43:15 +02:00 committed by GitHub
commit 4570db2a3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 268 additions and 38 deletions

View file

@ -141,6 +141,7 @@ zot --help
| `--model <id>` | Pick the model (see `--list-models`). |
| `--api-key <key>` | Override the API key. |
| `--base-url <url>` | Override the provider base URL (tests, self-hosted). |
| `--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. |
| `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). |
| `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |
| `--reasoning off\|minimum\|low\|medium\|high\|maximum` | Set thinking level on supported models (default: off). |

View file

@ -74,6 +74,9 @@ type Args struct {
// skill discovery, including built-ins.
WithSkills bool
// InsecureTLS skips TLS verification for custom inference endpoints.
InsecureTLS bool
// NoYolo turns on per-tool confirmation. Before each tool
// invocation the TUI prompts the user with the tool name + args
// and waits for an explicit yes/no. The user can also pick
@ -190,6 +193,8 @@ func ParseArgs(in []string) (Args, error) {
case "--with-skills", "--with-skill":
// Deprecated no-op: user skills are loaded by default.
a.WithSkills = true
case "--insecure":
a.InsecureTLS = true
case "--no-yolo":
a.NoYolo = true
case "--reasoning":
@ -373,6 +378,7 @@ func PrintHelp(version string) {
row{"--model ID", "model id (see --list-models)"},
row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},
row{"--base-url URL", "override provider api base url"},
row{"--insecure", "skip TLS certificate verification (for self-signed-cert endpoints)"},
row{"--reasoning off|minimum|low|medium|high|maximum", "set thinking level on supported models"},
row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"},
)

View file

@ -23,6 +23,7 @@ type Resolved struct {
AuthMethod string // "apikey" | "oauth" | "" (no credential yet)
AccountID string // ChatGPT account id (for openai oauth), "" otherwise
BaseURL string
InsecureTLS bool
CWD string
Reasoning string
Temperature *float32
@ -455,6 +456,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
model = fm.ID
}
explicitBaseURL := args.BaseURL != ""
// If the model defines a base URL (e.g. local ollama) and the
// user didn't pass --base-url, use the model's URL. For ollama,
// keep http://localhost:11434 as a fallback only after the model
@ -466,6 +469,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
args.BaseURL = "http://localhost:11434"
}
// Insecure TLS is intentionally scoped to explicit custom endpoints.
// Built-in provider base URLs, auth calls, and model discovery keep normal
// certificate verification even when --insecure is present.
insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
// If the model has a base URL, credentials are optional (local
// models like ollama don't need real API keys).
if resolvedModel.BaseURL != "" && credErr != nil {
@ -553,6 +561,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
AuthMethod: method,
AccountID: accountID,
BaseURL: args.BaseURL,
InsecureTLS: insecureTLS,
CWD: args.CWD,
Reasoning: reasoning,
Temperature: temperature,
@ -680,83 +689,84 @@ func (r Resolved) NewClient() provider.Client {
if !r.HasCredential() {
panic("NewClient called without credential; check HasCredential first")
}
wrap := r.withHTTPClient
switch r.Provider {
case "ollama":
return provider.NewOpenAI(r.Credential, r.BaseURL)
return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
case "kimi":
// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
inner := provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
if r.AuthMethod == "oauth" {
return r.wrapWithRefresh(inner)
}
return inner
case "moonshotai":
return provider.NewMoonshot(r.Credential, r.BaseURL)
return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
case "moonshotai-cn":
return provider.NewMoonshotCN(r.Credential, r.BaseURL)
return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
case "deepseek":
return provider.NewDeepSeek(r.Credential, r.BaseURL)
return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
case "openai":
return provider.NewOpenAI(r.Credential, r.BaseURL)
return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
case "openai-codex":
inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
return r.wrapWithRefresh(inner)
case "openai-responses":
// Public OpenAI Responses API (api.openai.com/v1/responses) via
// API key. Separate provider from `openai` (Chat Completions) and
// from `openai-codex` (ChatGPT subscription OAuth).
return provider.NewOpenAIResponses(r.Credential, r.BaseURL)
return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
case "google":
return provider.NewGemini(r.Credential, r.BaseURL)
return wrap(provider.NewGemini(r.Credential, r.BaseURL))
case "cerebras":
return provider.NewCerebras(r.Credential, r.BaseURL)
return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
case "groq":
return provider.NewGroq(r.Credential, r.BaseURL)
return wrap(provider.NewGroq(r.Credential, r.BaseURL))
case "xai":
return provider.NewXAI(r.Credential, r.BaseURL)
return wrap(provider.NewXAI(r.Credential, r.BaseURL))
case "together":
return provider.NewTogether(r.Credential, r.BaseURL)
return wrap(provider.NewTogether(r.Credential, r.BaseURL))
case "huggingface":
return provider.NewHuggingFace(r.Credential, r.BaseURL)
return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
case "openrouter":
return provider.NewOpenRouter(r.Credential, r.BaseURL)
return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
case "zai":
return provider.NewZAI(r.Credential, r.BaseURL)
return wrap(provider.NewZAI(r.Credential, r.BaseURL))
case "xiaomi":
return provider.NewXiaomi(r.Credential, r.BaseURL)
return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
case "xiaomi-token-plan-ams":
return provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL)
return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
case "xiaomi-token-plan-cn":
return provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL)
return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
case "xiaomi-token-plan-sgp":
return provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL)
return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
case "opencode":
return provider.NewOpenCode(r.Credential, r.BaseURL)
return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
case "opencode-go":
return provider.NewOpenCodeGo(r.Credential, r.BaseURL)
return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
case "minimax":
return provider.NewMinimaxAnthropic(r.Credential, r.BaseURL)
return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
case "minimax-cn":
return provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL)
return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
case "fireworks":
return provider.NewFireworksAnthropic(r.Credential, r.BaseURL)
return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
case "vercel-ai-gateway":
return provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL)
return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
case "mistral":
return provider.NewMistral(r.Credential, r.BaseURL)
return wrap(provider.NewMistral(r.Credential, r.BaseURL))
case "amazon-bedrock":
return provider.NewBedrock(r.Credential, r.BaseURL)
return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
case "google-vertex":
return provider.NewGoogleVertex(r.Credential, r.BaseURL)
return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
case "azure-openai-responses":
return provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL)
return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
case "github-copilot":
return provider.NewGithubCopilot(r.Credential, r.BaseURL)
return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
case "cloudflare-workers-ai":
return provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL)
return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
case "cloudflare-ai-gateway":
return provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)
return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
default:
// Custom providers: choose wire format from the models.json api field.
if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
@ -768,13 +778,20 @@ func (r Resolved) NewClient() provider.Client {
}
}
if r.AuthMethod == "oauth" {
inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
return r.wrapWithRefresh(inner)
}
return provider.NewAnthropic(r.Credential, r.BaseURL)
return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
}
}
func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
if !r.InsecureTLS {
return c
}
return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
}
// wrapWithRefresh wraps an OAuth client so the access token is
// refreshed automatically before each API call. Without this, long
// sessions (hours) silently fail when the 1-hour token expires.
@ -798,12 +815,12 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
factory := func(token string) provider.Client {
switch provName {
case "openai-codex":
return provider.NewOpenAICodex(token, accountID, baseURL)
return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
case "kimi":
// anthropic-messages on api.kimi.com/coding.
return provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders())
return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
default:
return provider.NewAnthropicOAuth(token, baseURL)
return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
}
}

View file

@ -1,6 +1,7 @@
package agent
import (
"net/http"
"os"
"path/filepath"
"strings"
@ -211,3 +212,69 @@ func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
}
}
}
func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
orig := http.DefaultTransport
t.Cleanup(func() { http.DefaultTransport = orig })
t.Setenv("ZOT_HOME", t.TempDir())
t.Setenv("OPENAI_API_KEY", "test-key")
resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved.InsecureTLS {
t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
}
assertDefaultTransportStillSecure(t)
resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if !resolved.InsecureTLS {
t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url")
}
assertDefaultTransportStillSecure(t)
}
func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
orig := http.DefaultTransport
t.Cleanup(func() { http.DefaultTransport = orig })
t.Setenv("ZOT_HOME", t.TempDir())
t.Setenv("OPENAI_API_KEY", "test-key")
if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil {
t.Fatal(err)
}
resolved, err := Resolve(Args{Provider: "openai"}, false)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved.InsecureTLS {
t.Fatal("InsecureTLS must not be set without a custom base URL")
}
assertDefaultTransportStillSecure(t)
resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if !resolved.InsecureTLS {
t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
}
assertDefaultTransportStillSecure(t)
}
func assertDefaultTransportStillSecure(t *testing.T) {
t.Helper()
tr, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return
}
if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
t.Fatal("http.DefaultTransport must not be made insecure")
}
}

View file

@ -46,6 +46,9 @@ type Config struct {
// which is on; false shows ignored entries. Toggle from /settings.
RespectGitignore *bool `json:"respect_gitignore,omitempty"`
// Insecure skips TLS verification for custom inference endpoints.
Insecure bool `json:"insecure,omitempty"`
// LastChangelogShown is the version whose release-notes
// dialog the user has already seen. When the running binary's
// version differs, the next interactive run shows the

View file

@ -0,0 +1,50 @@
package provider
import (
"crypto/tls"
"net/http"
)
// NewHTTPClient returns a provider HTTP client. When insecureTLS is true,
// only this client skips TLS certificate verification. The process-wide
// default transport is left untouched so auth, discovery, and other providers
// keep normal certificate validation.
func NewHTTPClient(insecureTLS bool) *http.Client {
if !insecureTLS {
return &http.Client{Timeout: 0}
}
tr, ok := http.DefaultTransport.(*http.Transport)
if ok {
tr = tr.Clone()
} else {
tr = &http.Transport{}
}
if tr.TLSClientConfig != nil {
tr.TLSClientConfig = tr.TLSClientConfig.Clone()
} else {
tr.TLSClientConfig = &tls.Config{}
}
tr.TLSClientConfig.InsecureSkipVerify = true //nolint:gosec
return &http.Client{Timeout: 0, Transport: tr}
}
// WithHTTPClient scopes an HTTP client to a concrete provider client.
// Unsupported clients are returned unchanged.
func WithHTTPClient(c Client, httpClient *http.Client) Client {
if httpClient == nil {
return c
}
switch v := c.(type) {
case *openaiClient:
v.http = httpClient
case *anthropicClient:
v.http = httpClient
case *geminiClient:
v.http = httpClient
case *bedrockClient:
v.http = httpClient
case *renamedClient:
v.inner = WithHTTPClient(v.inner, httpClient)
}
return c
}

View file

@ -0,0 +1,54 @@
package provider
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestNewHTTPClientDoesNotChangeDefaultTransport(t *testing.T) {
orig := http.DefaultTransport
t.Cleanup(func() { http.DefaultTransport = orig })
client := NewHTTPClient(true)
if http.DefaultTransport != orig {
t.Fatal("NewHTTPClient must not mutate http.DefaultTransport")
}
tr, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", client.Transport)
}
if tr.TLSClientConfig == nil || !tr.TLSClientConfig.InsecureSkipVerify {
t.Fatal("expected scoped InsecureSkipVerify=true in TLS config")
}
}
func TestScopedInsecureClientReachesTLSServer(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
orig := http.DefaultTransport
t.Cleanup(func() { http.DefaultTransport = orig })
secureClient := NewHTTPClient(false)
if _, err := secureClient.Get(srv.URL); err == nil {
t.Fatal("expected TLS error with secure client, got nil")
}
insecureClient := NewHTTPClient(true)
resp, err := insecureClient.Get(srv.URL)
if err != nil {
t.Fatalf("request failed with scoped insecure client: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d", resp.StatusCode)
}
defaultClient := &http.Client{}
if _, err := defaultClient.Get(srv.URL); err == nil {
t.Fatal("default client must still reject self-signed TLS")
}
}

View file

@ -342,9 +342,22 @@ func keyFromModifiedCode(code, mod int) (Key, bool) {
shift := bits&1 != 0
alt := bits&2 != 0
ctrl := bits&4 != 0
// Kitty keyboard protocol (CSI ... u) reports control keys as their
// codepoints: Esc=27, Enter=13, Tab=9, Backspace=127. Without the
// enhanced-mode handling these arrive as raw bytes; with it enabled
// they come through here, so map them back to their dedicated keys.
switch code {
case 13:
return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl}, true
case 27:
return Key{Kind: KeyEsc, Shift: shift, Alt: alt, Ctrl: ctrl}, true
case 9:
if shift {
return Key{Kind: KeyShiftTab, Alt: alt, Ctrl: ctrl}, true
}
return Key{Kind: KeyTab, Shift: shift, Alt: alt, Ctrl: ctrl}, true
case 127, 8:
return Key{Kind: KeyBackspace, Shift: shift, Alt: alt, Ctrl: ctrl}, true
}
if ctrl {
switch code {

View file

@ -30,6 +30,25 @@ func TestReaderParsesModifyOtherKeysCtrlC(t *testing.T) {
}
}
func TestReaderParsesCSIUEsc(t *testing.T) {
k := readKey(t, "\x1b[27u")
if k.Kind != KeyEsc {
t.Fatalf("Read kind=%v, want esc", k.Kind)
}
}
func TestReaderParsesCSIUTabAndBackspace(t *testing.T) {
if k := readKey(t, "\x1b[9u"); k.Kind != KeyTab {
t.Fatalf("Read kind=%v, want tab", k.Kind)
}
if k := readKey(t, "\x1b[9;2u"); k.Kind != KeyShiftTab {
t.Fatalf("Read kind=%v, want shift-tab", k.Kind)
}
if k := readKey(t, "\x1b[127u"); k.Kind != KeyBackspace {
t.Fatalf("Read kind=%v, want backspace", k.Kind)
}
}
func TestReaderParsesSGRMouseWheel(t *testing.T) {
cases := []struct {
seq string