detached ed5f96a

Fix keyless custom provider selection and authentication

commit
ed5f96a0d3902d4c222066aa5cd67942919734ee
author
patriceckhart · · 2026-09-28 19:26 UTC
parent
1b12f2c
signature
unsigned
12 files · +227 -42
M README.md +2 -2
@@ -609,7 +609,7 @@ A top-level provider key that is not a built-in id defines a custom provider. Gi
609609 }
610610 ```
611611
612-Custom providers are first-class: they appear in `--list-models`, `/model`, and `/login`. `models.json` never stores secrets. Supply the key through `/login`, `--api-key`, or a derived environment variable in upper snake case (so `my-company` reads `MY_COMPANY_API_KEY`). Because many self-hosted gateways do not expose a model-list endpoint, custom provider keys are accepted and stored without a verification probe; an invalid key surfaces on the first model call.
612+Custom providers are first-class: they appear in `--list-models`, `/model`, and `/login`. `models.json` never stores secrets. For endpoints that require authentication, supply the key through `/login`, `--api-key`, or a derived environment variable in upper snake case (so `my-company` reads `MY_COMPANY_API_KEY`). Custom providers with a configured base URL also work without a key. For a keyless endpoint that must ignore an existing stored, environment, or CLI key, set `"auth": "none"` on its provider in `models.json`. This also disables credentials for model discovery. Only use this with trusted endpoints. Because many self-hosted gateways do not expose a model-list endpoint, custom provider keys are accepted and stored without a verification probe; an invalid key surfaces on the first model call.
613613
614614 To retrieve this custom provider's key from a password manager, add a matching entry to `$ZOT_HOME/auth.json`:
615615
@@ -648,7 +648,7 @@ Set `"discover": true` on a custom provider to list its models from the OpenAI-c
648648 }
649649 ```
650650
651-zot queries `<baseUrl>/models` (or `<baseUrl>/v1/models` when the base has no version segment) with a 3 second timeout: in the background at startup, when `/model` opens, and when launching with a discovered model that is not yet in the catalog. The stored or environment API key is sent as a bearer token when present; a missing key is not an error. Discovered IDs get a conservative 32K context and 4K output default, and IDs containing `embed` are skipped. Entries in the provider's `models` array override discovered metadata for the same ID. The list stays in memory only, failed refreshes keep the previous snapshot, and `/model` reports the error. Background refresh never executes `api_key_command`.
651+zot queries `<baseUrl>/models` (or `<baseUrl>/v1/models` when the base has no version segment) with a 3 second timeout: in the background at startup, when `/model` opens, and when launching with a discovered model that is not yet in the catalog. The stored or environment API key is sent as a bearer token when present, unless the provider sets `"auth": "none"`; a missing key is not an error. Discovered IDs get a conservative 32K context and 4K output default, and IDs containing `embed` are skipped. Entries in the provider's `models` array override discovered metadata for the same ID. The list stays in memory only, failed refreshes keep the previous snapshot, and `/model` reports the error. Background refresh never executes `api_key_command`.
652652
653653 ### Kimi Code
654654
@@ -609,7 +609,7 @@ A top-level provider key that is not a built-in id defines a custom provider. Gi
609 }609 }
610 ```610 ```
611 611
612-Custom providers are first-class: they appear in `--list-models`, `/model`, and `/login`. `models.json` never stores secrets. Supply the key through `/login`, `--api-key`, or a derived environment variable in upper snake case (so `my-company` reads `MY_COMPANY_API_KEY`). Because many self-hosted gateways do not expose a model-list endpoint, custom provider keys are accepted and stored without a verification probe; an invalid key surfaces on the first model call.612+Custom providers are first-class: they appear in `--list-models`, `/model`, and `/login`. `models.json` never stores secrets. For endpoints that require authentication, supply the key through `/login`, `--api-key`, or a derived environment variable in upper snake case (so `my-company` reads `MY_COMPANY_API_KEY`). Custom providers with a configured base URL also work without a key. For a keyless endpoint that must ignore an existing stored, environment, or CLI key, set `"auth": "none"` on its provider in `models.json`. This also disables credentials for model discovery. Only use this with trusted endpoints. Because many self-hosted gateways do not expose a model-list endpoint, custom provider keys are accepted and stored without a verification probe; an invalid key surfaces on the first model call.
613 613
614 To retrieve this custom provider's key from a password manager, add a matching entry to `$ZOT_HOME/auth.json`:614 To retrieve this custom provider's key from a password manager, add a matching entry to `$ZOT_HOME/auth.json`:
615 615
@@ -648,7 +648,7 @@ Set `"discover": true` on a custom provider to list its models from the OpenAI-c
648 }648 }
649 ```649 ```
650 650
651-zot queries `<baseUrl>/models` (or `<baseUrl>/v1/models` when the base has no version segment) with a 3 second timeout: in the background at startup, when `/model` opens, and when launching with a discovered model that is not yet in the catalog. The stored or environment API key is sent as a bearer token when present; a missing key is not an error. Discovered IDs get a conservative 32K context and 4K output default, and IDs containing `embed` are skipped. Entries in the provider's `models` array override discovered metadata for the same ID. The list stays in memory only, failed refreshes keep the previous snapshot, and `/model` reports the error. Background refresh never executes `api_key_command`.651+zot queries `<baseUrl>/models` (or `<baseUrl>/v1/models` when the base has no version segment) with a 3 second timeout: in the background at startup, when `/model` opens, and when launching with a discovered model that is not yet in the catalog. The stored or environment API key is sent as a bearer token when present, unless the provider sets `"auth": "none"`; a missing key is not an error. Discovered IDs get a conservative 32K context and 4K output default, and IDs containing `embed` are skipped. Entries in the provider's `models` array override discovered metadata for the same ID. The list stays in memory only, failed refreshes keep the previous snapshot, and `/model` reports the error. Background refresh never executes `api_key_command`.
652 652
653 ### Kimi Code653 ### Kimi Code
654 654
M docs/providers.md +13 -9
@@ -470,11 +470,16 @@ unknown `api` value falls back to `openai` with a warning.
470470 ```
471471
472472 Custom providers are first-class: they appear in `--list-models`, `/model`, and
473-`/login`. `models.json` never stores secrets. Supply the key through `/login`,
474-`--api-key`, or a derived environment variable in upper snake case, so
475-`my-company` reads `MY_COMPANY_API_KEY`. Because many self-hosted gateways do
476-not expose a model-list endpoint, custom provider keys are accepted and stored
477-without a verification probe; an invalid key surfaces on the first model call.
473+`/login`. `models.json` never stores secrets. If the endpoint requires a key,
474+supply it through `/login`, `--api-key`, or a derived environment variable in
475+upper snake case, so `my-company` reads `MY_COMPANY_API_KEY`. Because many
476+self-hosted gateways do not expose a model-list endpoint, custom provider keys
477+are accepted and stored without a verification probe; an invalid key surfaces
478+on the first model call. Custom providers with a configured base URL also work
479+without a key. If a stored, environment, or CLI key must be ignored for a
480+keyless endpoint, set `"auth": "none"` on that provider in `models.json`. This
481+also suppresses credentials during live model discovery. Use only with trusted
482+endpoints, since requests will be unauthenticated even if a key is available.
478483
479484 To retrieve this custom provider's key from a password manager, add a matching
480485 entry to `$ZOT_HOME/auth.json`:
@@ -548,10 +553,9 @@ selection of a discovered model survives restarts even though the model is not
548553 in `models.json`. The `/models` wire format is assumed to be OpenAI style
549554 (`{"data": [{"id": ...}]}`) regardless of the provider's `api` setting.
550555
551-As with every custom provider, `/model` only lists it once a credential exists.
552-For a keyless local server, store any placeholder value through `/login` or the
553-derived environment variable. `--list-models` prints the catalog before the
554-background refresh completes, so discovered models may be missing there.
556+`/model` also lists custom providers with a configured base URL and no key.
557+`--list-models` prints the catalog before the background refresh completes,
558+so discovered models may be missing there.
555559
556560 ## Credential resolution
557561
@@ -470,11 +470,16 @@ unknown `api` value falls back to `openai` with a warning.
470 ```470 ```
471 471
472 Custom providers are first-class: they appear in `--list-models`, `/model`, and472 Custom providers are first-class: they appear in `--list-models`, `/model`, and
473-`/login`. `models.json` never stores secrets. Supply the key through `/login`,473+`/login`. `models.json` never stores secrets. If the endpoint requires a key,
474-`--api-key`, or a derived environment variable in upper snake case, so474+supply it through `/login`, `--api-key`, or a derived environment variable in
475-`my-company` reads `MY_COMPANY_API_KEY`. Because many self-hosted gateways do475+upper snake case, so `my-company` reads `MY_COMPANY_API_KEY`. Because many
476-not expose a model-list endpoint, custom provider keys are accepted and stored476+self-hosted gateways do not expose a model-list endpoint, custom provider keys
477-without a verification probe; an invalid key surfaces on the first model call.477+are accepted and stored without a verification probe; an invalid key surfaces
478+on the first model call. Custom providers with a configured base URL also work
479+without a key. If a stored, environment, or CLI key must be ignored for a
480+keyless endpoint, set `"auth": "none"` on that provider in `models.json`. This
481+also suppresses credentials during live model discovery. Use only with trusted
482+endpoints, since requests will be unauthenticated even if a key is available.
478 483
479 To retrieve this custom provider's key from a password manager, add a matching484 To retrieve this custom provider's key from a password manager, add a matching
480 entry to `$ZOT_HOME/auth.json`:485 entry to `$ZOT_HOME/auth.json`:
@@ -548,10 +553,9 @@ selection of a discovered model survives restarts even though the model is not
548 in `models.json`. The `/models` wire format is assumed to be OpenAI style553 in `models.json`. The `/models` wire format is assumed to be OpenAI style
549 (`{"data": [{"id": ...}]}`) regardless of the provider's `api` setting.554 (`{"data": [{"id": ...}]}`) regardless of the provider's `api` setting.
550 555
551-As with every custom provider, `/model` only lists it once a credential exists.556+`/model` also lists custom providers with a configured base URL and no key.
552-For a keyless local server, store any placeholder value through `/login` or the557+`--list-models` prints the catalog before the background refresh completes,
553-derived environment variable. `--list-models` prints the catalog before the558+so discovered models may be missing there.
554-background refresh completes, so discovered models may be missing there.
555 559
556 ## Credential resolution560 ## Credential resolution
557 561
M packages/agent/build.go +13 -3
@@ -367,7 +367,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
367367 accountID string
368368 credErr error
369369 )
370- if args.inheritedCredential != "" {
370+ customCfg, customProvider := provider.CustomProviders()[provName]
371+ keylessCustom := customProvider && !isBuiltinProvider(provName) && customCfg.NoAuth
372+ if keylessCustom {
373+ // Explicit keyless configuration takes precedence over CLI, env, and
374+ // stored credentials. Never execute a stored API key command.
375+ } else if args.inheritedCredential != "" {
371376 cred = args.inheritedCredential
372377 method = args.inheritedAuthMethod
373378 accountID = args.inheritedAccountID
@@ -380,7 +385,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
380385
381386 // Persist --api-key for custom providers so subsequent runs don't
382387 // need to pass it again.
383- if !isBuiltinProvider(provName) && args.APIKey != "" {
388+ if !isBuiltinProvider(provName) && !keylessCustom && args.APIKey != "" {
384389 if store := AuthStoreFor(); store != nil {
385390 _ = store.SetAPIKey(provName, args.APIKey)
386391 }
@@ -599,7 +604,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
599604 // If the model has a base URL, credentials are optional (local
600605 // models like ollama don't need real API keys).
601606 credentialOptional := false
602- if resolvedModel.BaseURL != "" && credErr != nil {
607+ if keylessCustom {
608+ if args.BaseURL == "" {
609+ return Resolved{}, fmt.Errorf("custom provider %q has auth none but no base URL", provName)
610+ }
611+ credentialOptional = true
612+ } else if resolvedModel.BaseURL != "" && credErr != nil {
603613 if _, isCustom := provider.CustomProviders()[provName]; isCustom {
604614 // A custom endpoint may be configured for keyless access. Keep the
605615 // credential empty so the provider does not send a placeholder token.
@@ -367,7 +367,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
367 accountID string367 accountID string
368 credErr error368 credErr error
369 )369 )
370- if args.inheritedCredential != "" {370+ customCfg, customProvider := provider.CustomProviders()[provName]
371+ keylessCustom := customProvider && !isBuiltinProvider(provName) && customCfg.NoAuth
372+ if keylessCustom {
373+ // Explicit keyless configuration takes precedence over CLI, env, and
374+ // stored credentials. Never execute a stored API key command.
375+ } else if args.inheritedCredential != "" {
371 cred = args.inheritedCredential376 cred = args.inheritedCredential
372 method = args.inheritedAuthMethod377 method = args.inheritedAuthMethod
373 accountID = args.inheritedAccountID378 accountID = args.inheritedAccountID
@@ -380,7 +385,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
380 385
381 // Persist --api-key for custom providers so subsequent runs don't386 // Persist --api-key for custom providers so subsequent runs don't
382 // need to pass it again.387 // need to pass it again.
383- if !isBuiltinProvider(provName) && args.APIKey != "" {388+ if !isBuiltinProvider(provName) && !keylessCustom && args.APIKey != "" {
384 if store := AuthStoreFor(); store != nil {389 if store := AuthStoreFor(); store != nil {
385 _ = store.SetAPIKey(provName, args.APIKey)390 _ = store.SetAPIKey(provName, args.APIKey)
386 }391 }
@@ -599,7 +604,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
599 // If the model has a base URL, credentials are optional (local604 // If the model has a base URL, credentials are optional (local
600 // models like ollama don't need real API keys).605 // models like ollama don't need real API keys).
601 credentialOptional := false606 credentialOptional := false
602- if resolvedModel.BaseURL != "" && credErr != nil {607+ if keylessCustom {
608+ if args.BaseURL == "" {
609+ return Resolved{}, fmt.Errorf("custom provider %q has auth none but no base URL", provName)
610+ }
611+ credentialOptional = true
612+ } else if resolvedModel.BaseURL != "" && credErr != nil {
603 if _, isCustom := provider.CustomProviders()[provName]; isCustom {613 if _, isCustom := provider.CustomProviders()[provName]; isCustom {
604 // A custom endpoint may be configured for keyless access. Keep the614 // A custom endpoint may be configured for keyless access. Keep the
605 // credential empty so the provider does not send a placeholder token.615 // credential empty so the provider does not send a placeholder token.
M packages/agent/build_test.go +97 -0
@@ -6,6 +6,7 @@ import (
66 "net/http/httptest"
77 "os"
88 "path/filepath"
9+ "slices"
910 "strings"
1011 "testing"
1112
@@ -609,6 +610,102 @@ func TestCustomProviderKeylessOpenAICompatOmitsAuthorization(t *testing.T) {
609610 }
610611 }
611612
613+func TestCustomProviderExplicitKeylessIgnoresStoredKey(t *testing.T) {
614+ for _, api := range []string{"openai", "openai-responses", "anthropic"} {
615+ t.Run(api, func(t *testing.T) {
616+ t.Setenv("ZOT_HOME", t.TempDir())
617+ t.Setenv("UNSLOTH_API_KEY", "env-key")
618+ authorization := make(chan string, 1)
619+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
620+ if _, ok := r.Header["Authorization"]; ok {
621+ authorization <- "Authorization present"
622+ } else if _, ok := r.Header["X-Api-Key"]; ok {
623+ authorization <- "X-Api-Key present"
624+ } else {
625+ authorization <- ""
626+ }
627+ w.Header().Set("Content-Type", "text/event-stream")
628+ w.WriteHeader(http.StatusOK)
629+ }))
630+ t.Cleanup(srv.Close)
631+ loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"`+srv.URL+`/v1","api":"`+api+`","auth":"none","models":[{"id":"qista"}]}}}`)
632+ if err := AuthStoreFor().SetAPIKey("unsloth", "stored-key"); err != nil {
633+ t.Fatal(err)
634+ }
635+ r, err := Resolve(Args{Provider: "unsloth", Model: "qista", APIKey: "cli-key", NoSkill: true}, true)
636+ if err != nil {
637+ t.Fatal(err)
638+ }
639+ if r.Credential != "" || !r.HasCredential() {
640+ t.Fatalf("keyless resolved credential = %q, usable = %v", r.Credential, r.HasCredential())
641+ }
642+ events, err := r.NewClient().Stream(context.Background(), provider.Request{
643+ Model: "qista", Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
644+ })
645+ if err != nil {
646+ t.Fatal(err)
647+ }
648+ for range events {
649+ }
650+ if got := <-authorization; got != "" {
651+ t.Fatalf("Authorization = %q, want omitted", got)
652+ }
653+ t.Setenv("UNSLOTH_API_KEY", "")
654+ stored, _, _, err := ResolveCredentialFull("unsloth", "")
655+ if err != nil || stored != "stored-key" {
656+ t.Fatalf("stored credential changed: %q, %v", stored, err)
657+ }
658+ })
659+ }
660+}
661+
662+func TestModelPickerProvidersIncludeKeylessCustomEndpoint(t *testing.T) {
663+ t.Setenv("ZOT_HOME", t.TempDir())
664+ t.Setenv("UNSLOTH_API_KEY", "")
665+ loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"http://127.0.0.1:8888/v1","api":"openai","models":[{"id":"qista"}]}}}`)
666+ if CredentialAvailable("unsloth") {
667+ t.Fatal("test requires no credential")
668+ }
669+ if !slices.Contains(modelPickerProviders(), "unsloth") {
670+ t.Fatal("keyless custom endpoint missing from model picker")
671+ }
672+ loadTestModelsJSON(t, `{"providers":{"unsloth":{"models":[{"id":"qista","baseUrl":"http://127.0.0.1:8888/v1"}]}}}`)
673+ if !slices.Contains(modelPickerProviders(), "unsloth") {
674+ t.Fatal("model-level keyless endpoint missing from model picker")
675+ }
676+}
677+
678+func TestCustomProviderWithKeyStillSendsAuthorization(t *testing.T) {
679+ t.Setenv("ZOT_HOME", t.TempDir())
680+ t.Setenv("UNSLOTH_API_KEY", "")
681+ authorization := make(chan string, 1)
682+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
683+ authorization <- r.Header.Get("Authorization")
684+ w.Header().Set("Content-Type", "text/event-stream")
685+ w.WriteHeader(http.StatusOK)
686+ }))
687+ t.Cleanup(srv.Close)
688+ loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"`+srv.URL+`/v1","api":"openai","models":[{"id":"qista"}]}}}`)
689+ if err := AuthStoreFor().SetAPIKey("unsloth", "stored-key"); err != nil {
690+ t.Fatal(err)
691+ }
692+ r, err := Resolve(Args{Provider: "unsloth", Model: "qista", NoSkill: true}, true)
693+ if err != nil {
694+ t.Fatal(err)
695+ }
696+ events, err := r.NewClient().Stream(context.Background(), provider.Request{
697+ Model: "qista", Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
698+ })
699+ if err != nil {
700+ t.Fatal(err)
701+ }
702+ for range events {
703+ }
704+ if got := <-authorization; got != "Bearer stored-key" {
705+ t.Fatalf("Authorization = %q, want stored key", got)
706+ }
707+}
708+
612709 func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
613710 t.Setenv("ZOT_HOME", t.TempDir())
614711 t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
@@ -6,6 +6,7 @@ import (
6 "net/http/httptest"6 "net/http/httptest"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "slices"
9 "strings"10 "strings"
10 "testing"11 "testing"
11 12
@@ -609,6 +610,102 @@ func TestCustomProviderKeylessOpenAICompatOmitsAuthorization(t *testing.T) {
609 }610 }
610 }611 }
611 612
613+func TestCustomProviderExplicitKeylessIgnoresStoredKey(t *testing.T) {
614+ for _, api := range []string{"openai", "openai-responses", "anthropic"} {
615+ t.Run(api, func(t *testing.T) {
616+ t.Setenv("ZOT_HOME", t.TempDir())
617+ t.Setenv("UNSLOTH_API_KEY", "env-key")
618+ authorization := make(chan string, 1)
619+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
620+ if _, ok := r.Header["Authorization"]; ok {
621+ authorization <- "Authorization present"
622+ } else if _, ok := r.Header["X-Api-Key"]; ok {
623+ authorization <- "X-Api-Key present"
624+ } else {
625+ authorization <- ""
626+ }
627+ w.Header().Set("Content-Type", "text/event-stream")
628+ w.WriteHeader(http.StatusOK)
629+ }))
630+ t.Cleanup(srv.Close)
631+ loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"`+srv.URL+`/v1","api":"`+api+`","auth":"none","models":[{"id":"qista"}]}}}`)
632+ if err := AuthStoreFor().SetAPIKey("unsloth", "stored-key"); err != nil {
633+ t.Fatal(err)
634+ }
635+ r, err := Resolve(Args{Provider: "unsloth", Model: "qista", APIKey: "cli-key", NoSkill: true}, true)
636+ if err != nil {
637+ t.Fatal(err)
638+ }
639+ if r.Credential != "" || !r.HasCredential() {
640+ t.Fatalf("keyless resolved credential = %q, usable = %v", r.Credential, r.HasCredential())
641+ }
642+ events, err := r.NewClient().Stream(context.Background(), provider.Request{
643+ Model: "qista", Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
644+ })
645+ if err != nil {
646+ t.Fatal(err)
647+ }
648+ for range events {
649+ }
650+ if got := <-authorization; got != "" {
651+ t.Fatalf("Authorization = %q, want omitted", got)
652+ }
653+ t.Setenv("UNSLOTH_API_KEY", "")
654+ stored, _, _, err := ResolveCredentialFull("unsloth", "")
655+ if err != nil || stored != "stored-key" {
656+ t.Fatalf("stored credential changed: %q, %v", stored, err)
657+ }
658+ })
659+ }
660+}
661+
662+func TestModelPickerProvidersIncludeKeylessCustomEndpoint(t *testing.T) {
663+ t.Setenv("ZOT_HOME", t.TempDir())
664+ t.Setenv("UNSLOTH_API_KEY", "")
665+ loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"http://127.0.0.1:8888/v1","api":"openai","models":[{"id":"qista"}]}}}`)
666+ if CredentialAvailable("unsloth") {
667+ t.Fatal("test requires no credential")
668+ }
669+ if !slices.Contains(modelPickerProviders(), "unsloth") {
670+ t.Fatal("keyless custom endpoint missing from model picker")
671+ }
672+ loadTestModelsJSON(t, `{"providers":{"unsloth":{"models":[{"id":"qista","baseUrl":"http://127.0.0.1:8888/v1"}]}}}`)
673+ if !slices.Contains(modelPickerProviders(), "unsloth") {
674+ t.Fatal("model-level keyless endpoint missing from model picker")
675+ }
676+}
677+
678+func TestCustomProviderWithKeyStillSendsAuthorization(t *testing.T) {
679+ t.Setenv("ZOT_HOME", t.TempDir())
680+ t.Setenv("UNSLOTH_API_KEY", "")
681+ authorization := make(chan string, 1)
682+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
683+ authorization <- r.Header.Get("Authorization")
684+ w.Header().Set("Content-Type", "text/event-stream")
685+ w.WriteHeader(http.StatusOK)
686+ }))
687+ t.Cleanup(srv.Close)
688+ loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"`+srv.URL+`/v1","api":"openai","models":[{"id":"qista"}]}}}`)
689+ if err := AuthStoreFor().SetAPIKey("unsloth", "stored-key"); err != nil {
690+ t.Fatal(err)
691+ }
692+ r, err := Resolve(Args{Provider: "unsloth", Model: "qista", NoSkill: true}, true)
693+ if err != nil {
694+ t.Fatal(err)
695+ }
696+ events, err := r.NewClient().Stream(context.Background(), provider.Request{
697+ Model: "qista", Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
698+ })
699+ if err != nil {
700+ t.Fatal(err)
701+ }
702+ for range events {
703+ }
704+ if got := <-authorization; got != "Bearer stored-key" {
705+ t.Fatalf("Authorization = %q, want stored key", got)
706+ }
707+}
708+
612 func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {709 func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
613 t.Setenv("ZOT_HOME", t.TempDir())710 t.Setenv("ZOT_HOME", t.TempDir())
614 t.Setenv("LOCAL_PROXY_API_KEY", "test-key")711 t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
M packages/agent/cli.go +4 -25
@@ -1437,31 +1437,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14371437 SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
14381438 BuildAgentFor: buildAgentFor,
14391439 BuildAgentForRescue: buildAgentForRescue,
1440- LoggedInProviders: func() []string {
1441- var out []string
1442- seen := map[string]bool{}
1443- for _, p := range knownProviders {
1444- if CredentialAvailable(p) && !seen[p] {
1445- out = append(out, p)
1446- seen[p] = true
1447- }
1448- }
1449- // Include custom providers that have credentials stored.
1450- for p := range provider.CustomProviders() {
1451- if CredentialAvailable(p) && !seen[p] {
1452- out = append(out, p)
1453- seen[p] = true
1454- }
1455- }
1456- // Ollama models are always available (no auth needed).
1457- if !seen["ollama"] {
1458- out = append(out, "ollama")
1459- }
1460- return out
1461- },
1462- NewSession: newSession,
1463- LoadSession: loadSession,
1464- ChangeCWD: changeCWD,
1440+ LoggedInProviders: modelPickerProviders,
1441+ NewSession: newSession,
1442+ LoadSession: loadSession,
1443+ ChangeCWD: changeCWD,
14651444 CurrentSessionPath: func() string {
14661445 if sess == nil {
14671446 return ""
@@ -1437,31 +1437,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1437 SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,1437 SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
1438 BuildAgentFor: buildAgentFor,1438 BuildAgentFor: buildAgentFor,
1439 BuildAgentForRescue: buildAgentForRescue,1439 BuildAgentForRescue: buildAgentForRescue,
1440- LoggedInProviders: func() []string {1440+ LoggedInProviders: modelPickerProviders,
1441- var out []string1441+ NewSession: newSession,
1442- seen := map[string]bool{}1442+ LoadSession: loadSession,
1443- for _, p := range knownProviders {1443+ ChangeCWD: changeCWD,
1444- if CredentialAvailable(p) && !seen[p] {
1445- out = append(out, p)
1446- seen[p] = true
1447- }
1448- }
1449- // Include custom providers that have credentials stored.
1450- for p := range provider.CustomProviders() {
1451- if CredentialAvailable(p) && !seen[p] {
1452- out = append(out, p)
1453- seen[p] = true
1454- }
1455- }
1456- // Ollama models are always available (no auth needed).
1457- if !seen["ollama"] {
1458- out = append(out, "ollama")
1459- }
1460- return out
1461- },
1462- NewSession: newSession,
1463- LoadSession: loadSession,
1464- ChangeCWD: changeCWD,
1465 CurrentSessionPath: func() string {1444 CurrentSessionPath: func() string {
1466 if sess == nil {1445 if sess == nil {
1467 return ""1446 return ""
M packages/agent/customdiscovery.go +3 -0
@@ -43,6 +43,9 @@ func CustomDiscoveryConfigured() bool {
4343 // unauthenticated requests. skip is true when the only key is command-backed
4444 // and mode forbids running the command.
4545 func customProviderKey(ctx context.Context, name string, mode apiKeyCommandMode) (key string, skip bool, err error) {
46+ if cfg, ok := provider.CustomProviders()[name]; ok && cfg.NoAuth && !isBuiltinProvider(name) {
47+ return "", false, nil
48+ }
4649 if v := os.Getenv(normalizeCustomProviderEnvVar(name) + "_API_KEY"); v != "" {
4750 return v, false, nil
4851 }
@@ -43,6 +43,9 @@ func CustomDiscoveryConfigured() bool {
43 // unauthenticated requests. skip is true when the only key is command-backed43 // unauthenticated requests. skip is true when the only key is command-backed
44 // and mode forbids running the command.44 // and mode forbids running the command.
45 func customProviderKey(ctx context.Context, name string, mode apiKeyCommandMode) (key string, skip bool, err error) {45 func customProviderKey(ctx context.Context, name string, mode apiKeyCommandMode) (key string, skip bool, err error) {
46+ if cfg, ok := provider.CustomProviders()[name]; ok && cfg.NoAuth && !isBuiltinProvider(name) {
47+ return "", false, nil
48+ }
46 if v := os.Getenv(normalizeCustomProviderEnvVar(name) + "_API_KEY"); v != "" {49 if v := os.Getenv(normalizeCustomProviderEnvVar(name) + "_API_KEY"); v != "" {
47 return v, false, nil50 return v, false, nil
48 }51 }
M packages/agent/customdiscovery_test.go +17 -0
@@ -136,6 +136,23 @@ func TestRefreshCustomProviderModelsUsesStoredKeyAndKeepsUserOverrides(t *testin
136136 }
137137 }
138138
139+func TestRefreshCustomProviderModelsExplicitKeylessIgnoresStoredKey(t *testing.T) {
140+ t.Setenv("ZOT_HOME", t.TempDir())
141+ t.Setenv("M4_API_KEY", "env-key")
142+ var requests atomic.Int32
143+ server := newModelsServer(t, &requests, "", "qwen3-coder")
144+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers":{"m4":{"baseUrl":%q,"discover":true,"auth":"none"}}}`, server.URL+"/v1"))
145+ if err := AuthStoreFor().SetAPIKey("m4", "stored-key"); err != nil {
146+ t.Fatal(err)
147+ }
148+ if err := RefreshCustomProviderModels(context.Background()); err != nil {
149+ t.Fatal(err)
150+ }
151+ if requests.Load() != 1 {
152+ t.Fatalf("requests = %d, want 1", requests.Load())
153+ }
154+}
155+
139156 func TestRefreshCustomProviderModelsBackgroundSkipsKeyCommand(t *testing.T) {
140157 home := t.TempDir()
141158 t.Setenv("ZOT_HOME", home)
@@ -136,6 +136,23 @@ func TestRefreshCustomProviderModelsUsesStoredKeyAndKeepsUserOverrides(t *testin
136 }136 }
137 }137 }
138 138
139+func TestRefreshCustomProviderModelsExplicitKeylessIgnoresStoredKey(t *testing.T) {
140+ t.Setenv("ZOT_HOME", t.TempDir())
141+ t.Setenv("M4_API_KEY", "env-key")
142+ var requests atomic.Int32
143+ server := newModelsServer(t, &requests, "", "qwen3-coder")
144+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers":{"m4":{"baseUrl":%q,"discover":true,"auth":"none"}}}`, server.URL+"/v1"))
145+ if err := AuthStoreFor().SetAPIKey("m4", "stored-key"); err != nil {
146+ t.Fatal(err)
147+ }
148+ if err := RefreshCustomProviderModels(context.Background()); err != nil {
149+ t.Fatal(err)
150+ }
151+ if requests.Load() != 1 {
152+ t.Fatalf("requests = %d, want 1", requests.Load())
153+ }
154+}
155+
139 func TestRefreshCustomProviderModelsBackgroundSkipsKeyCommand(t *testing.T) {156 func TestRefreshCustomProviderModelsBackgroundSkipsKeyCommand(t *testing.T) {
140 home := t.TempDir()157 home := t.TempDir()
141 t.Setenv("ZOT_HOME", home)158 t.Setenv("ZOT_HOME", home)
A packages/agent/modelpicker_providers.go +38 -0
@@ -0,0 +1,38 @@
1+package agent
2+
3+import "github.com/patriceckhart/zot/packages/provider"
4+
5+// modelPickerProviders returns providers whose models can be selected without
6+// prompting for credentials. Keyless custom endpoints need a configured URL.
7+func modelPickerProviders() []string {
8+ var out []string
9+ seen := map[string]bool{}
10+ for _, p := range knownProviders {
11+ if CredentialAvailable(p) && !seen[p] {
12+ out = append(out, p)
13+ seen[p] = true
14+ }
15+ }
16+ for p, cfg := range provider.CustomProviders() {
17+ if seen[p] {
18+ continue
19+ }
20+ if CredentialAvailable(p) || (!isBuiltinProvider(p) && (cfg.BaseURL != "" || customProviderHasModelURL(p))) {
21+ out = append(out, p)
22+ seen[p] = true
23+ }
24+ }
25+ if !seen["ollama"] {
26+ out = append(out, "ollama")
27+ }
28+ return out
29+}
30+
31+func customProviderHasModelURL(name string) bool {
32+ for _, m := range provider.ModelsForProvider(name) {
33+ if m.Source == "user" && m.BaseURL != "" {
34+ return true
35+ }
36+ }
37+ return false
38+}
@@ -0,0 +1,38 @@
1+package agent
2+
3+import "github.com/patriceckhart/zot/packages/provider"
4+
5+// modelPickerProviders returns providers whose models can be selected without
6+// prompting for credentials. Keyless custom endpoints need a configured URL.
7+func modelPickerProviders() []string {
8+ var out []string
9+ seen := map[string]bool{}
10+ for _, p := range knownProviders {
11+ if CredentialAvailable(p) && !seen[p] {
12+ out = append(out, p)
13+ seen[p] = true
14+ }
15+ }
16+ for p, cfg := range provider.CustomProviders() {
17+ if seen[p] {
18+ continue
19+ }
20+ if CredentialAvailable(p) || (!isBuiltinProvider(p) && (cfg.BaseURL != "" || customProviderHasModelURL(p))) {
21+ out = append(out, p)
22+ seen[p] = true
23+ }
24+ }
25+ if !seen["ollama"] {
26+ out = append(out, "ollama")
27+ }
28+ return out
29+}
30+
31+func customProviderHasModelURL(name string) bool {
32+ for _, m := range provider.ModelsForProvider(name) {
33+ if m.Source == "user" && m.BaseURL != "" {
34+ return true
35+ }
36+ }
37+ return false
38+}
M packages/provider/anthropic.go +3 -1
@@ -564,7 +564,9 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event
564564 // Remove x-api-key entirely by NOT setting it.
565565 } else {
566566 httpReq.Header.Set("accept", "text/event-stream")
567- httpReq.Header.Set("x-api-key", c.apiKey)
567+ if c.apiKey != "" {
568+ httpReq.Header.Set("x-api-key", c.apiKey)
569+ }
568570 }
569571 // Extra headers (set by anthropic-messages-compatible third parties
570572 // — kimi-coding's X-Msh-*, copilot's Editor-Plugin-Version, etc.).
@@ -564,7 +564,9 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event
564 // Remove x-api-key entirely by NOT setting it.564 // Remove x-api-key entirely by NOT setting it.
565 } else {565 } else {
566 httpReq.Header.Set("accept", "text/event-stream")566 httpReq.Header.Set("accept", "text/event-stream")
567- httpReq.Header.Set("x-api-key", c.apiKey)567+ if c.apiKey != "" {
568+ httpReq.Header.Set("x-api-key", c.apiKey)
569+ }
568 }570 }
569 // Extra headers (set by anthropic-messages-compatible third parties571 // Extra headers (set by anthropic-messages-compatible third parties
570 // — kimi-coding's X-Msh-*, copilot's Editor-Plugin-Version, etc.).572 // — kimi-coding's X-Msh-*, copilot's Editor-Plugin-Version, etc.).
M packages/provider/openai_responses.go +5 -1
@@ -38,7 +38,11 @@ func (t *openaiResponsesTransport) RoundTrip(req *http.Request) (*http.Response,
3838 clone.Header.Del("chatgpt-account-id")
3939 clone.Header.Del("openai-beta")
4040 clone.Header.Del("originator")
41- // Keep Authorization: Bearer <key> as set by the codex client.
41+ // The shared Codex client sets a Bearer header even with an empty key.
42+ // Keyless custom Responses endpoints need no Authorization header at all.
43+ if strings.TrimSpace(clone.Header.Get("authorization")) == "Bearer" {
44+ clone.Header.Del("authorization")
45+ }
4246 return t.inner.RoundTrip(clone)
4347 }
4448
@@ -38,7 +38,11 @@ func (t *openaiResponsesTransport) RoundTrip(req *http.Request) (*http.Response,
38 clone.Header.Del("chatgpt-account-id")38 clone.Header.Del("chatgpt-account-id")
39 clone.Header.Del("openai-beta")39 clone.Header.Del("openai-beta")
40 clone.Header.Del("originator")40 clone.Header.Del("originator")
41- // Keep Authorization: Bearer <key> as set by the codex client.41+ // The shared Codex client sets a Bearer header even with an empty key.
42+ // Keyless custom Responses endpoints need no Authorization header at all.
43+ if strings.TrimSpace(clone.Header.Get("authorization")) == "Bearer" {
44+ clone.Header.Del("authorization")
45+ }
42 return t.inner.RoundTrip(clone)46 return t.inner.RoundTrip(clone)
43 }47 }
44 48
M packages/provider/usermodels.go +14 -1
@@ -37,7 +37,8 @@ type UserModelsFile struct {
3737 // UserProvider groups models under a provider key.
3838 type UserProvider struct {
3939 BaseURL string `json:"baseUrl,omitempty"`
40- API string `json:"api,omitempty"` // "openai" (default), "openai-responses", or "anthropic"
40+ API string `json:"api,omitempty"` // "openai" (default), "openai-responses", or "anthropic"
41+ Auth string `json:"auth,omitempty"` // "none" disables credentials, including stored keys
4142 // Discover enables live model listing from the provider-level
4243 // baseUrl's /models endpoint. Off by default so existing custom
4344 // providers never gain unexpected network traffic.
@@ -50,6 +51,7 @@ type UserProvider struct {
5051 type CustomProviderConfig struct {
5152 BaseURL string
5253 API string // "openai", "openai-responses", or "anthropic"
54+ NoAuth bool // explicitly configured for keyless access
5355 // Discover reports whether live model discovery is enabled. It is
5456 // only true when a provider-level BaseURL is present.
5557 Discover bool
@@ -172,7 +174,17 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
172174 warnings = append(warnings, fmt.Sprintf("models.json: provider %q sets discover without a provider-level baseUrl; discovery disabled", providerName))
173175 discover = false
174176 }
177+ if prov.Auth != "" && prov.BaseURL == "" && !hasModelBaseURL {
178+ warnings = append(warnings, fmt.Sprintf("models.json: provider %q sets auth without a baseUrl; ignored", providerName))
179+ }
175180 if prov.BaseURL != "" || prov.API != "" || hasModelBaseURL {
181+ auth := strings.ToLower(strings.TrimSpace(prov.Auth))
182+ if auth != "" && auth != "none" {
183+ warnings = append(warnings, fmt.Sprintf("models.json: provider %q has unknown auth %q; using credentials when available", providerName, prov.Auth))
184+ }
185+ if prov.BaseURL == "" && !hasModelBaseURL {
186+ auth = ""
187+ }
176188 api := strings.ToLower(strings.TrimSpace(prov.API))
177189 if api == "" {
178190 api = "openai"
@@ -192,6 +204,7 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
192204 customProviders[normalized] = CustomProviderConfig{
193205 BaseURL: prov.BaseURL,
194206 API: api,
207+ NoAuth: auth == "none",
195208 Discover: discover,
196209 }
197210 }
@@ -37,7 +37,8 @@ type UserModelsFile struct {
37 // UserProvider groups models under a provider key.37 // UserProvider groups models under a provider key.
38 type UserProvider struct {38 type UserProvider struct {
39 BaseURL string `json:"baseUrl,omitempty"`39 BaseURL string `json:"baseUrl,omitempty"`
40- API string `json:"api,omitempty"` // "openai" (default), "openai-responses", or "anthropic"40+ API string `json:"api,omitempty"` // "openai" (default), "openai-responses", or "anthropic"
41+ Auth string `json:"auth,omitempty"` // "none" disables credentials, including stored keys
41 // Discover enables live model listing from the provider-level42 // Discover enables live model listing from the provider-level
42 // baseUrl's /models endpoint. Off by default so existing custom43 // baseUrl's /models endpoint. Off by default so existing custom
43 // providers never gain unexpected network traffic.44 // providers never gain unexpected network traffic.
@@ -50,6 +51,7 @@ type UserProvider struct {
50 type CustomProviderConfig struct {51 type CustomProviderConfig struct {
51 BaseURL string52 BaseURL string
52 API string // "openai", "openai-responses", or "anthropic"53 API string // "openai", "openai-responses", or "anthropic"
54+ NoAuth bool // explicitly configured for keyless access
53 // Discover reports whether live model discovery is enabled. It is55 // Discover reports whether live model discovery is enabled. It is
54 // only true when a provider-level BaseURL is present.56 // only true when a provider-level BaseURL is present.
55 Discover bool57 Discover bool
@@ -172,7 +174,17 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
172 warnings = append(warnings, fmt.Sprintf("models.json: provider %q sets discover without a provider-level baseUrl; discovery disabled", providerName))174 warnings = append(warnings, fmt.Sprintf("models.json: provider %q sets discover without a provider-level baseUrl; discovery disabled", providerName))
173 discover = false175 discover = false
174 }176 }
177+ if prov.Auth != "" && prov.BaseURL == "" && !hasModelBaseURL {
178+ warnings = append(warnings, fmt.Sprintf("models.json: provider %q sets auth without a baseUrl; ignored", providerName))
179+ }
175 if prov.BaseURL != "" || prov.API != "" || hasModelBaseURL {180 if prov.BaseURL != "" || prov.API != "" || hasModelBaseURL {
181+ auth := strings.ToLower(strings.TrimSpace(prov.Auth))
182+ if auth != "" && auth != "none" {
183+ warnings = append(warnings, fmt.Sprintf("models.json: provider %q has unknown auth %q; using credentials when available", providerName, prov.Auth))
184+ }
185+ if prov.BaseURL == "" && !hasModelBaseURL {
186+ auth = ""
187+ }
176 api := strings.ToLower(strings.TrimSpace(prov.API))188 api := strings.ToLower(strings.TrimSpace(prov.API))
177 if api == "" {189 if api == "" {
178 api = "openai"190 api = "openai"
@@ -192,6 +204,7 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
192 customProviders[normalized] = CustomProviderConfig{204 customProviders[normalized] = CustomProviderConfig{
193 BaseURL: prov.BaseURL,205 BaseURL: prov.BaseURL,
194 API: api,206 API: api,
207+ NoAuth: auth == "none",
195 Discover: discover,208 Discover: discover,
196 }209 }
197 }210 }
M packages/provider/usermodels_test.go +18 -0
@@ -169,6 +169,24 @@ func TestLoadUserModelsWarnsOnUnknownAPI(t *testing.T) {
169169 }
170170 }
171171
172+func TestLoadUserModelsKeylessAuthRequiresEndpoint(t *testing.T) {
173+ path := filepath.Join(t.TempDir(), "models.json")
174+ if err := os.WriteFile(path, []byte(`{"providers":{
175+ "keyless":{"baseUrl":"http://localhost:8888/v1","auth":"none","models":[{"id":"m"}]},
176+ "unknown":{"baseUrl":"http://localhost:9999/v1","auth":"typo","models":[{"id":"m"}]},
177+ "no-url":{"auth":"none","models":[{"id":"m"}]}
178+ }}`), 0o644); err != nil {
179+ t.Fatal(err)
180+ }
181+ _, warnings := LoadUserModelsWithWarnings(path)
182+ if !CustomProviders()["keyless"].NoAuth || CustomProviders()["unknown"].NoAuth || CustomProviders()["no-url"].NoAuth {
183+ t.Fatalf("invalid keyless configuration: %+v", CustomProviders())
184+ }
185+ if len(warnings) != 2 || !strings.Contains(strings.Join(warnings, "\n"), "unknown auth") || !strings.Contains(strings.Join(warnings, "\n"), "without a baseUrl") {
186+ t.Fatalf("warnings = %v, want unknown auth and missing URL warnings", warnings)
187+ }
188+}
189+
172190 func TestLoadUserModelsDiscoverFlag(t *testing.T) {
173191 dir := t.TempDir()
174192 path := filepath.Join(dir, "models.json")
@@ -169,6 +169,24 @@ func TestLoadUserModelsWarnsOnUnknownAPI(t *testing.T) {
169 }169 }
170 }170 }
171 171
172+func TestLoadUserModelsKeylessAuthRequiresEndpoint(t *testing.T) {
173+ path := filepath.Join(t.TempDir(), "models.json")
174+ if err := os.WriteFile(path, []byte(`{"providers":{
175+ "keyless":{"baseUrl":"http://localhost:8888/v1","auth":"none","models":[{"id":"m"}]},
176+ "unknown":{"baseUrl":"http://localhost:9999/v1","auth":"typo","models":[{"id":"m"}]},
177+ "no-url":{"auth":"none","models":[{"id":"m"}]}
178+ }}`), 0o644); err != nil {
179+ t.Fatal(err)
180+ }
181+ _, warnings := LoadUserModelsWithWarnings(path)
182+ if !CustomProviders()["keyless"].NoAuth || CustomProviders()["unknown"].NoAuth || CustomProviders()["no-url"].NoAuth {
183+ t.Fatalf("invalid keyless configuration: %+v", CustomProviders())
184+ }
185+ if len(warnings) != 2 || !strings.Contains(strings.Join(warnings, "\n"), "unknown auth") || !strings.Contains(strings.Join(warnings, "\n"), "without a baseUrl") {
186+ t.Fatalf("warnings = %v, want unknown auth and missing URL warnings", warnings)
187+ }
188+}
189+
172 func TestLoadUserModelsDiscoverFlag(t *testing.T) {190 func TestLoadUserModelsDiscoverFlag(t *testing.T) {
173 dir := t.TempDir()191 dir := t.TempDir()
174 path := filepath.Join(dir, "models.json")192 path := filepath.Join(dir, "models.json")

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close