detached 66f54fd

Merge branch 'main' into feature/more-spinner-messages

commit
66f54fdaae2fab99a6415c7744ff9e403e26ccdf
author
Patric Eckhart · · 2026-09-28 19:46 UTC
committer
GitHub · · 2026-09-28 19:46 UTC
merge of
56e2e7e + 885ddb0 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
15 files · +405 -93
M README.md +3 -3
@@ -382,7 +382,7 @@ Shows previous sessions for the current working directory, newest first, with ti
382382
383383 Five ops on the current session. `/session` alone opens a picker; each is also runnable directly.
384384
385-- **`/session timeline`**. Replaces the chat area with a read-only timeline of the current system prompt, user and assistant messages, and tool calls. The header shows provider-reported context usage and an estimated split between system prompt, tool definitions, and messages. The split uses a simple byte estimate, so it is directional rather than tokenizer-exact. Use `up` / `down` or `pgup` / `pgdn` to select an event, `tab` / `shift+tab` to inspect its summary, payload, result, schema, and transcript-derived timing, and `/` to search. `ctrl+e` writes a JSON export to `~/Downloads` (falling back to the home directory) with `0600` permissions on platforms that support Unix file modes. Images are represented by MIME type and byte size instead of embedding their data. Press `esc` to return to chat.
385+- **`/session timeline`**. Replaces the chat area with a read-only timeline of the current system prompt, user and assistant messages, and tool calls. The header shows provider-reported context usage and an estimated split between system prompt, tool definitions, and messages. The split uses a simple byte estimate, so it is directional rather than tokenizer-exact. Use `up` / `down` or `pgup` / `pgdn` to select an event, `tab` / `shift+tab` to inspect its summary, payload, result, schema, and transcript-derived timing, `left` / `right` to scroll detail one line, `home` / `end` to jump within detail, and `/` to search. Available reasoning summaries appear as separate events with Markdown rendering, but the timeline shows saved context only, not a live reasoning stream. Providers may not expose readable reasoning. `ctrl+e` writes a JSON export to `~/Downloads` (falling back to the home directory) with `0600` permissions on platforms that support Unix file modes. Images are represented by MIME type and byte size instead of embedding their data. Press `esc` to return to chat.
386386 - **`/session export [path]`**. Writes the running transcript to a portable `.zotsession` file. Default destination is `~/Downloads/<timestamp>-<session-id>-<prompt-slug>.zotsession`. Pass a path to override; a directory is fine (a dated name is built inside), a bare name gets `.zotsession` appended. The meta's cwd is stripped on the way out so the recipient doesn't see your filesystem layout.
387387
388388 **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/swarm` subagents are NOT included.** Their transcripts, unix-socket inboxes, and per-agent session files are all machine-local; a `.zotsession` is just a chat transcript and has no way to revive a unix socket on another box. If you want the conversation, copy it out of the dashboard manually.
@@ -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
@@ -382,7 +382,7 @@ Shows previous sessions for the current working directory, newest first, with ti
382 382
383 Five ops on the current session. `/session` alone opens a picker; each is also runnable directly.383 Five ops on the current session. `/session` alone opens a picker; each is also runnable directly.
384 384
385-- **`/session timeline`**. Replaces the chat area with a read-only timeline of the current system prompt, user and assistant messages, and tool calls. The header shows provider-reported context usage and an estimated split between system prompt, tool definitions, and messages. The split uses a simple byte estimate, so it is directional rather than tokenizer-exact. Use `up` / `down` or `pgup` / `pgdn` to select an event, `tab` / `shift+tab` to inspect its summary, payload, result, schema, and transcript-derived timing, and `/` to search. `ctrl+e` writes a JSON export to `~/Downloads` (falling back to the home directory) with `0600` permissions on platforms that support Unix file modes. Images are represented by MIME type and byte size instead of embedding their data. Press `esc` to return to chat.385+- **`/session timeline`**. Replaces the chat area with a read-only timeline of the current system prompt, user and assistant messages, and tool calls. The header shows provider-reported context usage and an estimated split between system prompt, tool definitions, and messages. The split uses a simple byte estimate, so it is directional rather than tokenizer-exact. Use `up` / `down` or `pgup` / `pgdn` to select an event, `tab` / `shift+tab` to inspect its summary, payload, result, schema, and transcript-derived timing, `left` / `right` to scroll detail one line, `home` / `end` to jump within detail, and `/` to search. Available reasoning summaries appear as separate events with Markdown rendering, but the timeline shows saved context only, not a live reasoning stream. Providers may not expose readable reasoning. `ctrl+e` writes a JSON export to `~/Downloads` (falling back to the home directory) with `0600` permissions on platforms that support Unix file modes. Images are represented by MIME type and byte size instead of embedding their data. Press `esc` to return to chat.
386 - **`/session export [path]`**. Writes the running transcript to a portable `.zotsession` file. Default destination is `~/Downloads/<timestamp>-<session-id>-<prompt-slug>.zotsession`. Pass a path to override; a directory is fine (a dated name is built inside), a bare name gets `.zotsession` appended. The meta's cwd is stripped on the way out so the recipient doesn't see your filesystem layout.386 - **`/session export [path]`**. Writes the running transcript to a portable `.zotsession` file. Default destination is `~/Downloads/<timestamp>-<session-id>-<prompt-slug>.zotsession`. Pass a path to override; a directory is fine (a dated name is built inside), a bare name gets `.zotsession` appended. The meta's cwd is stripped on the way out so the recipient doesn't see your filesystem layout.
387 387
388 **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/swarm` subagents are NOT included.** Their transcripts, unix-socket inboxes, and per-agent session files are all machine-local; a `.zotsession` is just a chat transcript and has no way to revive a unix socket on another box. If you want the conversation, copy it out of the dashboard manually.388 **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/swarm` subagents are NOT included.** Their transcripts, unix-socket inboxes, and per-agent session files are all machine-local; a `.zotsession` is just a chat transcript and has no way to revive a unix socket on another box. If you want the conversation, copy it out of the dashboard manually.
@@ -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 +63 -41
@@ -66,14 +66,18 @@ type Resolved struct {
6666 // Bookkeeping for MergeExtensionTools. Captured at Resolve time
6767 // so the system prompt can be rebuilt later without re-running
6868 // resolve.
69- systemAppend []string
70- systemCustom string
71- systemCustomSet bool
72- toolDescriptions map[string]string
69+ credentialOptional bool
70+ systemAppend []string
71+ systemCustom string
72+ systemCustomSet bool
73+ toolDescriptions map[string]string
7374 }
7475
75-// HasCredential reports whether a credential was resolved.
76-func (r Resolved) HasCredential() bool { return r.Credential != "" }
76+// HasCredential reports whether the resolved configuration can create a client.
77+// Configured custom endpoints may intentionally omit an API credential.
78+func (r Resolved) HasCredential() bool {
79+ return r.Credential != "" || r.credentialOptional
80+}
7781
7882 // MergeExtensionTools folds every tool registered by an extension
7983 // into r's ToolRegistry and re-renders the system prompt's tool
@@ -363,7 +367,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
363367 accountID string
364368 credErr error
365369 )
366- 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 != "" {
367376 cred = args.inheritedCredential
368377 method = args.inheritedAuthMethod
369378 accountID = args.inheritedAccountID
@@ -376,7 +385,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
376385
377386 // Persist --api-key for custom providers so subsequent runs don't
378387 // need to pass it again.
379- if !isBuiltinProvider(provName) && args.APIKey != "" {
388+ if !isBuiltinProvider(provName) && !keylessCustom && args.APIKey != "" {
380389 if store := AuthStoreFor(); store != nil {
381390 _ = store.SetAPIKey(provName, args.APIKey)
382391 }
@@ -594,13 +603,25 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
594603
595604 // If the model has a base URL, credentials are optional (local
596605 // models like ollama don't need real API keys).
597- if resolvedModel.BaseURL != "" && credErr != nil {
598- cred = "ollama"
599- if provName == "amazon-bedrock" {
600- // The default AWS CLI profile is resolved by the Bedrock client,
601- // not the general credential resolver. Never pass the local-model
602- // placeholder as a bearer token.
603- cred = "<aws>"
606+ credentialOptional := false
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 {
613+ if _, isCustom := provider.CustomProviders()[provName]; isCustom {
614+ // A custom endpoint may be configured for keyless access. Keep the
615+ // credential empty so the provider does not send a placeholder token.
616+ credentialOptional = true
617+ } else {
618+ cred = "ollama"
619+ if provName == "amazon-bedrock" {
620+ // The default AWS CLI profile is resolved by the Bedrock client,
621+ // not the general credential resolver. Never pass the local-model
622+ // placeholder as a bearer token.
623+ cred = "<aws>"
624+ }
604625 }
605626 credErr = nil
606627 requireCred = false
@@ -713,29 +734,30 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
713734 max := args.MaxSteps // 0 = unlimited
714735
715736 return Resolved{
716- Provider: provName,
717- Model: model,
718- Credential: cred,
719- AuthMethod: method,
720- AccountID: accountID,
721- BaseURL: args.BaseURL,
722- InsecureTLS: insecureTLS,
723- CWD: args.CWD,
724- Reasoning: reasoning,
725- Temperature: temperature,
726- ToolRegistry: reg,
727- ToolSummary: summaries,
728- SystemPrompt: sys,
729- MaxSteps: max,
730- MaxOutput: resolvedModel.MaxOutput,
731- Sandbox: sandbox,
732- SkillTool: skillTool,
733- SkillDiagnostics: skillDiagnostics,
734- ContextFiles: contextFiles,
735- systemAppend: append_,
736- systemCustom: custom,
737- systemCustomSet: customSet,
738- toolDescriptions: descMapFromSummaries(summaries),
737+ Provider: provName,
738+ Model: model,
739+ Credential: cred,
740+ AuthMethod: method,
741+ AccountID: accountID,
742+ BaseURL: args.BaseURL,
743+ InsecureTLS: insecureTLS,
744+ CWD: args.CWD,
745+ Reasoning: reasoning,
746+ Temperature: temperature,
747+ ToolRegistry: reg,
748+ ToolSummary: summaries,
749+ SystemPrompt: sys,
750+ MaxSteps: max,
751+ MaxOutput: resolvedModel.MaxOutput,
752+ Sandbox: sandbox,
753+ SkillTool: skillTool,
754+ SkillDiagnostics: skillDiagnostics,
755+ ContextFiles: contextFiles,
756+ credentialOptional: credentialOptional,
757+ systemAppend: append_,
758+ systemCustom: custom,
759+ systemCustomSet: customSet,
760+ toolDescriptions: descMapFromSummaries(summaries),
739761 }, nil
740762 }
741763
@@ -844,11 +866,11 @@ func descMapFromSummaries(summaries []ToolSummary) map[string]string {
844866 }
845867
846868 // NewClient returns a provider.Client for r, choosing the auth mode
847-// based on r.AuthMethod. Panics if no credential is present; callers
848-// must check HasCredential() first.
869+// based on r.AuthMethod. Panics if no credential or usable keyless
870+// endpoint is present; callers must check HasCredential() first.
849871 func (r Resolved) NewClient() provider.Client {
850872 if !r.HasCredential() {
851- panic("NewClient called without credential; check HasCredential first")
873+ panic("NewClient called without credential or keyless endpoint; check HasCredential first")
852874 }
853875 wrap := r.withHTTPClient
854876 switch r.Provider {
@@ -66,14 +66,18 @@ type Resolved struct {
66 // Bookkeeping for MergeExtensionTools. Captured at Resolve time66 // Bookkeeping for MergeExtensionTools. Captured at Resolve time
67 // so the system prompt can be rebuilt later without re-running67 // so the system prompt can be rebuilt later without re-running
68 // resolve.68 // resolve.
69- systemAppend []string69+ credentialOptional bool
70- systemCustom string70+ systemAppend []string
71- systemCustomSet bool71+ systemCustom string
72- toolDescriptions map[string]string72+ systemCustomSet bool
73+ toolDescriptions map[string]string
73 }74 }
74 75
75-// HasCredential reports whether a credential was resolved.76+// HasCredential reports whether the resolved configuration can create a client.
76-func (r Resolved) HasCredential() bool { return r.Credential != "" }77+// Configured custom endpoints may intentionally omit an API credential.
78+func (r Resolved) HasCredential() bool {
79+ return r.Credential != "" || r.credentialOptional
80+}
77 81
78 // MergeExtensionTools folds every tool registered by an extension82 // MergeExtensionTools folds every tool registered by an extension
79 // into r's ToolRegistry and re-renders the system prompt's tool83 // into r's ToolRegistry and re-renders the system prompt's tool
@@ -363,7 +367,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
363 accountID string367 accountID string
364 credErr error368 credErr error
365 )369 )
366- 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 != "" {
367 cred = args.inheritedCredential376 cred = args.inheritedCredential
368 method = args.inheritedAuthMethod377 method = args.inheritedAuthMethod
369 accountID = args.inheritedAccountID378 accountID = args.inheritedAccountID
@@ -376,7 +385,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
376 385
377 // Persist --api-key for custom providers so subsequent runs don't386 // Persist --api-key for custom providers so subsequent runs don't
378 // need to pass it again.387 // need to pass it again.
379- if !isBuiltinProvider(provName) && args.APIKey != "" {388+ if !isBuiltinProvider(provName) && !keylessCustom && args.APIKey != "" {
380 if store := AuthStoreFor(); store != nil {389 if store := AuthStoreFor(); store != nil {
381 _ = store.SetAPIKey(provName, args.APIKey)390 _ = store.SetAPIKey(provName, args.APIKey)
382 }391 }
@@ -594,13 +603,25 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
594 603
595 // If the model has a base URL, credentials are optional (local604 // If the model has a base URL, credentials are optional (local
596 // models like ollama don't need real API keys).605 // models like ollama don't need real API keys).
597- if resolvedModel.BaseURL != "" && credErr != nil {606+ credentialOptional := false
598- cred = "ollama"607+ if keylessCustom {
599- if provName == "amazon-bedrock" {608+ if args.BaseURL == "" {
600- // The default AWS CLI profile is resolved by the Bedrock client,609+ return Resolved{}, fmt.Errorf("custom provider %q has auth none but no base URL", provName)
601- // not the general credential resolver. Never pass the local-model610+ }
602- // placeholder as a bearer token.611+ credentialOptional = true
603- cred = "<aws>"612+ } else if resolvedModel.BaseURL != "" && credErr != nil {
613+ if _, isCustom := provider.CustomProviders()[provName]; isCustom {
614+ // A custom endpoint may be configured for keyless access. Keep the
615+ // credential empty so the provider does not send a placeholder token.
616+ credentialOptional = true
617+ } else {
618+ cred = "ollama"
619+ if provName == "amazon-bedrock" {
620+ // The default AWS CLI profile is resolved by the Bedrock client,
621+ // not the general credential resolver. Never pass the local-model
622+ // placeholder as a bearer token.
623+ cred = "<aws>"
624+ }
604 }625 }
605 credErr = nil626 credErr = nil
606 requireCred = false627 requireCred = false
@@ -713,29 +734,30 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
713 max := args.MaxSteps // 0 = unlimited734 max := args.MaxSteps // 0 = unlimited
714 735
715 return Resolved{736 return Resolved{
716- Provider: provName,737+ Provider: provName,
717- Model: model,738+ Model: model,
718- Credential: cred,739+ Credential: cred,
719- AuthMethod: method,740+ AuthMethod: method,
720- AccountID: accountID,741+ AccountID: accountID,
721- BaseURL: args.BaseURL,742+ BaseURL: args.BaseURL,
722- InsecureTLS: insecureTLS,743+ InsecureTLS: insecureTLS,
723- CWD: args.CWD,744+ CWD: args.CWD,
724- Reasoning: reasoning,745+ Reasoning: reasoning,
725- Temperature: temperature,746+ Temperature: temperature,
726- ToolRegistry: reg,747+ ToolRegistry: reg,
727- ToolSummary: summaries,748+ ToolSummary: summaries,
728- SystemPrompt: sys,749+ SystemPrompt: sys,
729- MaxSteps: max,750+ MaxSteps: max,
730- MaxOutput: resolvedModel.MaxOutput,751+ MaxOutput: resolvedModel.MaxOutput,
731- Sandbox: sandbox,752+ Sandbox: sandbox,
732- SkillTool: skillTool,753+ SkillTool: skillTool,
733- SkillDiagnostics: skillDiagnostics,754+ SkillDiagnostics: skillDiagnostics,
734- ContextFiles: contextFiles,755+ ContextFiles: contextFiles,
735- systemAppend: append_,756+ credentialOptional: credentialOptional,
736- systemCustom: custom,757+ systemAppend: append_,
737- systemCustomSet: customSet,758+ systemCustom: custom,
738- toolDescriptions: descMapFromSummaries(summaries),759+ systemCustomSet: customSet,
760+ toolDescriptions: descMapFromSummaries(summaries),
739 }, nil761 }, nil
740 }762 }
741 763
@@ -844,11 +866,11 @@ func descMapFromSummaries(summaries []ToolSummary) map[string]string {
844 }866 }
845 867
846 // NewClient returns a provider.Client for r, choosing the auth mode868 // NewClient returns a provider.Client for r, choosing the auth mode
847-// based on r.AuthMethod. Panics if no credential is present; callers869+// based on r.AuthMethod. Panics if no credential or usable keyless
848-// must check HasCredential() first.870+// endpoint is present; callers must check HasCredential() first.
849 func (r Resolved) NewClient() provider.Client {871 func (r Resolved) NewClient() provider.Client {
850 if !r.HasCredential() {872 if !r.HasCredential() {
851- panic("NewClient called without credential; check HasCredential first")873+ panic("NewClient called without credential or keyless endpoint; check HasCredential first")
852 }874 }
853 wrap := r.withHTTPClient875 wrap := r.withHTTPClient
854 switch r.Provider {876 switch r.Provider {
M packages/agent/build_test.go +137 -0
@@ -6,6 +6,7 @@ import (
66 "net/http/httptest"
77 "os"
88 "path/filepath"
9+ "slices"
910 "strings"
1011 "testing"
1112
@@ -569,6 +570,142 @@ func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) {
569570 }
570571 }
571572
573+func TestCustomProviderKeylessOpenAICompatOmitsAuthorization(t *testing.T) {
574+ t.Setenv("ZOT_HOME", t.TempDir())
575+ authorization := make(chan string, 1)
576+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
577+ authorization <- r.Header.Get("Authorization")
578+ if got := r.Header.Get("Authorization"); got != "" {
579+ http.Error(w, `{"error":"unexpected authorization"}`, http.StatusUnauthorized)
580+ return
581+ }
582+ w.Header().Set("Content-Type", "text/event-stream")
583+ w.WriteHeader(http.StatusOK)
584+ }))
585+ t.Cleanup(srv.Close)
586+
587+ loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"`+srv.URL+`/v1","api":"openai","models":[{"id":"qista"}]}}}`)
588+ r, err := Resolve(Args{Provider: "unsloth", Model: "qista", NoSkill: true}, true)
589+ if err != nil {
590+ t.Fatalf("Resolve failed: %v", err)
591+ }
592+ if r.Credential != "" {
593+ t.Fatalf("credential = %q, want empty for a keyless endpoint", r.Credential)
594+ }
595+ if !r.HasCredential() {
596+ t.Fatal("keyless custom endpoint should still be usable")
597+ }
598+
599+ events, err := r.NewClient().Stream(context.Background(), provider.Request{
600+ Model: "qista",
601+ Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
602+ })
603+ if err != nil {
604+ t.Fatalf("Stream failed: %v", err)
605+ }
606+ for range events {
607+ }
608+ if got := <-authorization; got != "" {
609+ t.Fatalf("Authorization = %q, want header omitted", got)
610+ }
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+
572709 func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
573710 t.Setenv("ZOT_HOME", t.TempDir())
574711 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
@@ -569,6 +570,142 @@ func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) {
569 }570 }
570 }571 }
571 572
573+func TestCustomProviderKeylessOpenAICompatOmitsAuthorization(t *testing.T) {
574+ t.Setenv("ZOT_HOME", t.TempDir())
575+ authorization := make(chan string, 1)
576+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
577+ authorization <- r.Header.Get("Authorization")
578+ if got := r.Header.Get("Authorization"); got != "" {
579+ http.Error(w, `{"error":"unexpected authorization"}`, http.StatusUnauthorized)
580+ return
581+ }
582+ w.Header().Set("Content-Type", "text/event-stream")
583+ w.WriteHeader(http.StatusOK)
584+ }))
585+ t.Cleanup(srv.Close)
586+
587+ loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"`+srv.URL+`/v1","api":"openai","models":[{"id":"qista"}]}}}`)
588+ r, err := Resolve(Args{Provider: "unsloth", Model: "qista", NoSkill: true}, true)
589+ if err != nil {
590+ t.Fatalf("Resolve failed: %v", err)
591+ }
592+ if r.Credential != "" {
593+ t.Fatalf("credential = %q, want empty for a keyless endpoint", r.Credential)
594+ }
595+ if !r.HasCredential() {
596+ t.Fatal("keyless custom endpoint should still be usable")
597+ }
598+
599+ events, err := r.NewClient().Stream(context.Background(), provider.Request{
600+ Model: "qista",
601+ Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
602+ })
603+ if err != nil {
604+ t.Fatalf("Stream failed: %v", err)
605+ }
606+ for range events {
607+ }
608+ if got := <-authorization; got != "" {
609+ t.Fatalf("Authorization = %q, want header omitted", got)
610+ }
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+
572 func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {709 func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
573 t.Setenv("ZOT_HOME", t.TempDir())710 t.Setenv("ZOT_HOME", t.TempDir())
574 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/agent/modes/timeline_view.go +44 -11
@@ -44,6 +44,7 @@ type timelineView struct {
4444 active bool
4545 cursor int
4646 tab int
47+ detailTop int
4748 searching bool
4849 filter string
4950 notice string
@@ -64,6 +65,7 @@ func (v *timelineView) Open(data timelineData) {
6465 v.filter = ""
6566 v.notice = ""
6667 v.tab = 0
68+ v.detailTop = 0
6769 v.cursor = len(buildTimelineEntries(data)) - 1
6870 if v.cursor < 0 {
6971 v.cursor = 0
@@ -75,6 +77,7 @@ func (v *timelineView) Close() {
7577 v.searching = false
7678 v.filter = ""
7779 v.notice = ""
80+ v.detailTop = 0
7881 }
7982
8083 func (v *timelineView) SetNotice(s string) { v.notice = s }
@@ -83,7 +86,18 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction {
8386 entries := filterTimelineEntries(buildTimelineEntries(data), v.filter)
8487 clampTimelineCursor(v, len(entries))
8588
89+ previousCursor, previousTab := v.cursor, v.tab
8690 switch k.Kind {
91+ case tui.KeyLeft:
92+ if v.detailTop > 0 {
93+ v.detailTop--
94+ }
95+ case tui.KeyRight:
96+ v.detailTop++
97+ case tui.KeyHome:
98+ v.detailTop = 0
99+ case tui.KeyEnd:
100+ v.detailTop = int(^uint(0) >> 1)
87101 case tui.KeyUp:
88102 if v.cursor > 0 {
89103 v.cursor--
@@ -111,11 +125,13 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction {
111125 r := []rune(v.filter)
112126 v.filter = string(r[:len(r)-1])
113127 v.cursor = 0
128+ v.detailTop = 0
114129 }
115130 case tui.KeyPaste:
116131 if v.searching {
117132 v.filter += singleLinePaste(k.Paste)
118133 v.cursor = 0
134+ v.detailTop = 0
119135 }
120136 case tui.KeyRune:
121137 if !v.searching && k.Rune == '/' {
@@ -124,12 +140,14 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction {
124140 } else if v.searching && !k.Ctrl && !k.Alt && !k.Super {
125141 v.filter += string(k.Rune)
126142 v.cursor = 0
143+ v.detailTop = 0
127144 }
128145 case tui.KeyEsc, tui.KeyCtrlC:
129146 if v.searching {
130147 if v.filter != "" {
131148 v.filter = ""
132149 v.cursor = 0
150+ v.detailTop = 0
133151 } else {
134152 v.searching = false
135153 }
@@ -138,6 +156,9 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction {
138156 v.Close()
139157 return timelineAction{Close: true}
140158 }
159+ if v.cursor != previousCursor || v.tab != previousTab {
160+ v.detailTop = 0
161+ }
141162 return timelineAction{}
142163 }
143164
@@ -167,7 +188,7 @@ func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData
167188 lines := []string{frameHeader(th, "timeline", width)}
168189 lines = append(lines, renderTimelineContext(th, width, data)...)
169190
170- hint := "↑/↓ select pgup/pgdn move tab details / search ctrl+e export esc chat"
191+ hint := "↑/↓ select tab details ←/→ scroll detail home/end / search ctrl+e export esc chat"
171192 if v.searching {
172193 hint = "search: " + v.filter + "_ (esc clears)"
173194 }
@@ -211,7 +232,15 @@ func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData
211232 if detailRows < 3 {
212233 detailRows = 3
213234 }
214- lines = append(lines, renderTimelineDetail(th, width, entries[v.cursor], v.tab, detailRows)...)
235+ detail := timelineDetailRows(th, width, entries[v.cursor], v.tab)
236+ maxTop := len(detail) - detailRows
237+ if maxTop < 0 {
238+ maxTop = 0
239+ }
240+ if v.detailTop > maxTop {
241+ v.detailTop = maxTop
242+ }
243+ lines = append(lines, detail[v.detailTop:min(v.detailTop+detailRows, len(detail))]...)
215244 }
216245 if v.notice != "" {
217246 lines = append(lines, th.FG256(th.Tool, fitTimelineLine(v.notice, width)))
@@ -249,6 +278,14 @@ func buildTimelineEntries(data timelineData) []timelineEntry {
249278 if message.Role == provider.RoleUser {
250279 turn++
251280 }
281+ for _, content := range message.Content {
282+ if block, ok := content.(provider.ReasoningBlock); ok && strings.TrimSpace(block.Summary) != "" {
283+ entries = append(entries, timelineEntry{
284+ Kind: "reasoning", Turn: turn, Label: "REASONING", Summary: firstTimelineLine(block.Summary),
285+ Time: message.Time, Payload: block.Summary,
286+ })
287+ }
288+ }
252289 textParts := timelineMessageText(message)
253290 if len(textParts) > 0 {
254291 entry := timelineEntry{
@@ -294,10 +331,6 @@ func timelineMessageText(message provider.Message) []string {
294331 if strings.TrimSpace(block.Text) != "" {
295332 out = append(out, block.Text)
296333 }
297- case provider.ReasoningBlock:
298- if strings.TrimSpace(block.Summary) != "" {
299- out = append(out, "reasoning: "+block.Summary)
300- }
301334 case provider.ImageBlock:
302335 out = append(out, fmt.Sprintf("[image %s, %d bytes]", block.MimeType, len(block.Data)))
303336 }
@@ -383,7 +416,7 @@ func timelineColor(th tui.Theme, entry timelineEntry, line string) string {
383416 switch entry.Kind {
384417 case "user":
385418 color = th.User
386- case "assistant":
419+ case "assistant", "reasoning":
387420 color = th.Assistant
388421 case "tool":
389422 color = th.Tool
@@ -436,7 +469,7 @@ func timelineTabsWidth(start, end int) int {
436469 return width
437470 }
438471
439-func renderTimelineDetail(th tui.Theme, width int, entry timelineEntry, tab, maxRows int) []string {
472+func timelineDetailRows(th tui.Theme, width int, entry timelineEntry, tab int) []string {
440473 var detail string
441474 switch tab {
442475 case 0:
@@ -461,13 +494,13 @@ func renderTimelineDetail(th tui.Theme, width int, entry timelineEntry, tab, max
461494 if strings.TrimSpace(detail) == "" {
462495 detail = "not available for this event"
463496 }
497+ if entry.Kind == "reasoning" && (tab == 0 || tab == 1) {
498+ return renderDialogMarkdownRows(entry.Payload, th, width)
499+ }
464500 var rows []string
465501 for _, line := range strings.Split(detail, "\n") {
466502 for _, wrapped := range tui.WrapANSILine(line, width-2) {
467503 rows = append(rows, " "+th.FG256(th.ToolOut, wrapped))
468- if len(rows) >= maxRows {
469- return rows
470- }
471504 }
472505 }
473506 return rows
@@ -44,6 +44,7 @@ type timelineView struct {
44 active bool44 active bool
45 cursor int45 cursor int
46 tab int46 tab int
47+ detailTop int
47 searching bool48 searching bool
48 filter string49 filter string
49 notice string50 notice string
@@ -64,6 +65,7 @@ func (v *timelineView) Open(data timelineData) {
64 v.filter = ""65 v.filter = ""
65 v.notice = ""66 v.notice = ""
66 v.tab = 067 v.tab = 0
68+ v.detailTop = 0
67 v.cursor = len(buildTimelineEntries(data)) - 169 v.cursor = len(buildTimelineEntries(data)) - 1
68 if v.cursor < 0 {70 if v.cursor < 0 {
69 v.cursor = 071 v.cursor = 0
@@ -75,6 +77,7 @@ func (v *timelineView) Close() {
75 v.searching = false77 v.searching = false
76 v.filter = ""78 v.filter = ""
77 v.notice = ""79 v.notice = ""
80+ v.detailTop = 0
78 }81 }
79 82
80 func (v *timelineView) SetNotice(s string) { v.notice = s }83 func (v *timelineView) SetNotice(s string) { v.notice = s }
@@ -83,7 +86,18 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction {
83 entries := filterTimelineEntries(buildTimelineEntries(data), v.filter)86 entries := filterTimelineEntries(buildTimelineEntries(data), v.filter)
84 clampTimelineCursor(v, len(entries))87 clampTimelineCursor(v, len(entries))
85 88
89+ previousCursor, previousTab := v.cursor, v.tab
86 switch k.Kind {90 switch k.Kind {
91+ case tui.KeyLeft:
92+ if v.detailTop > 0 {
93+ v.detailTop--
94+ }
95+ case tui.KeyRight:
96+ v.detailTop++
97+ case tui.KeyHome:
98+ v.detailTop = 0
99+ case tui.KeyEnd:
100+ v.detailTop = int(^uint(0) >> 1)
87 case tui.KeyUp:101 case tui.KeyUp:
88 if v.cursor > 0 {102 if v.cursor > 0 {
89 v.cursor--103 v.cursor--
@@ -111,11 +125,13 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction {
111 r := []rune(v.filter)125 r := []rune(v.filter)
112 v.filter = string(r[:len(r)-1])126 v.filter = string(r[:len(r)-1])
113 v.cursor = 0127 v.cursor = 0
128+ v.detailTop = 0
114 }129 }
115 case tui.KeyPaste:130 case tui.KeyPaste:
116 if v.searching {131 if v.searching {
117 v.filter += singleLinePaste(k.Paste)132 v.filter += singleLinePaste(k.Paste)
118 v.cursor = 0133 v.cursor = 0
134+ v.detailTop = 0
119 }135 }
120 case tui.KeyRune:136 case tui.KeyRune:
121 if !v.searching && k.Rune == '/' {137 if !v.searching && k.Rune == '/' {
@@ -124,12 +140,14 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction {
124 } else if v.searching && !k.Ctrl && !k.Alt && !k.Super {140 } else if v.searching && !k.Ctrl && !k.Alt && !k.Super {
125 v.filter += string(k.Rune)141 v.filter += string(k.Rune)
126 v.cursor = 0142 v.cursor = 0
143+ v.detailTop = 0
127 }144 }
128 case tui.KeyEsc, tui.KeyCtrlC:145 case tui.KeyEsc, tui.KeyCtrlC:
129 if v.searching {146 if v.searching {
130 if v.filter != "" {147 if v.filter != "" {
131 v.filter = ""148 v.filter = ""
132 v.cursor = 0149 v.cursor = 0
150+ v.detailTop = 0
133 } else {151 } else {
134 v.searching = false152 v.searching = false
135 }153 }
@@ -138,6 +156,9 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction {
138 v.Close()156 v.Close()
139 return timelineAction{Close: true}157 return timelineAction{Close: true}
140 }158 }
159+ if v.cursor != previousCursor || v.tab != previousTab {
160+ v.detailTop = 0
161+ }
141 return timelineAction{}162 return timelineAction{}
142 }163 }
143 164
@@ -167,7 +188,7 @@ func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData
167 lines := []string{frameHeader(th, "timeline", width)}188 lines := []string{frameHeader(th, "timeline", width)}
168 lines = append(lines, renderTimelineContext(th, width, data)...)189 lines = append(lines, renderTimelineContext(th, width, data)...)
169 190
170- hint := "↑/↓ select pgup/pgdn move tab details / search ctrl+e export esc chat"191+ hint := "↑/↓ select tab details ←/→ scroll detail home/end / search ctrl+e export esc chat"
171 if v.searching {192 if v.searching {
172 hint = "search: " + v.filter + "_ (esc clears)"193 hint = "search: " + v.filter + "_ (esc clears)"
173 }194 }
@@ -211,7 +232,15 @@ func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData
211 if detailRows < 3 {232 if detailRows < 3 {
212 detailRows = 3233 detailRows = 3
213 }234 }
214- lines = append(lines, renderTimelineDetail(th, width, entries[v.cursor], v.tab, detailRows)...)235+ detail := timelineDetailRows(th, width, entries[v.cursor], v.tab)
236+ maxTop := len(detail) - detailRows
237+ if maxTop < 0 {
238+ maxTop = 0
239+ }
240+ if v.detailTop > maxTop {
241+ v.detailTop = maxTop
242+ }
243+ lines = append(lines, detail[v.detailTop:min(v.detailTop+detailRows, len(detail))]...)
215 }244 }
216 if v.notice != "" {245 if v.notice != "" {
217 lines = append(lines, th.FG256(th.Tool, fitTimelineLine(v.notice, width)))246 lines = append(lines, th.FG256(th.Tool, fitTimelineLine(v.notice, width)))
@@ -249,6 +278,14 @@ func buildTimelineEntries(data timelineData) []timelineEntry {
249 if message.Role == provider.RoleUser {278 if message.Role == provider.RoleUser {
250 turn++279 turn++
251 }280 }
281+ for _, content := range message.Content {
282+ if block, ok := content.(provider.ReasoningBlock); ok && strings.TrimSpace(block.Summary) != "" {
283+ entries = append(entries, timelineEntry{
284+ Kind: "reasoning", Turn: turn, Label: "REASONING", Summary: firstTimelineLine(block.Summary),
285+ Time: message.Time, Payload: block.Summary,
286+ })
287+ }
288+ }
252 textParts := timelineMessageText(message)289 textParts := timelineMessageText(message)
253 if len(textParts) > 0 {290 if len(textParts) > 0 {
254 entry := timelineEntry{291 entry := timelineEntry{
@@ -294,10 +331,6 @@ func timelineMessageText(message provider.Message) []string {
294 if strings.TrimSpace(block.Text) != "" {331 if strings.TrimSpace(block.Text) != "" {
295 out = append(out, block.Text)332 out = append(out, block.Text)
296 }333 }
297- case provider.ReasoningBlock:
298- if strings.TrimSpace(block.Summary) != "" {
299- out = append(out, "reasoning: "+block.Summary)
300- }
301 case provider.ImageBlock:334 case provider.ImageBlock:
302 out = append(out, fmt.Sprintf("[image %s, %d bytes]", block.MimeType, len(block.Data)))335 out = append(out, fmt.Sprintf("[image %s, %d bytes]", block.MimeType, len(block.Data)))
303 }336 }
@@ -383,7 +416,7 @@ func timelineColor(th tui.Theme, entry timelineEntry, line string) string {
383 switch entry.Kind {416 switch entry.Kind {
384 case "user":417 case "user":
385 color = th.User418 color = th.User
386- case "assistant":419+ case "assistant", "reasoning":
387 color = th.Assistant420 color = th.Assistant
388 case "tool":421 case "tool":
389 color = th.Tool422 color = th.Tool
@@ -436,7 +469,7 @@ func timelineTabsWidth(start, end int) int {
436 return width469 return width
437 }470 }
438 471
439-func renderTimelineDetail(th tui.Theme, width int, entry timelineEntry, tab, maxRows int) []string {472+func timelineDetailRows(th tui.Theme, width int, entry timelineEntry, tab int) []string {
440 var detail string473 var detail string
441 switch tab {474 switch tab {
442 case 0:475 case 0:
@@ -461,13 +494,13 @@ func renderTimelineDetail(th tui.Theme, width int, entry timelineEntry, tab, max
461 if strings.TrimSpace(detail) == "" {494 if strings.TrimSpace(detail) == "" {
462 detail = "not available for this event"495 detail = "not available for this event"
463 }496 }
497+ if entry.Kind == "reasoning" && (tab == 0 || tab == 1) {
498+ return renderDialogMarkdownRows(entry.Payload, th, width)
499+ }
464 var rows []string500 var rows []string
465 for _, line := range strings.Split(detail, "\n") {501 for _, line := range strings.Split(detail, "\n") {
466 for _, wrapped := range tui.WrapANSILine(line, width-2) {502 for _, wrapped := range tui.WrapANSILine(line, width-2) {
467 rows = append(rows, " "+th.FG256(th.ToolOut, wrapped))503 rows = append(rows, " "+th.FG256(th.ToolOut, wrapped))
468- if len(rows) >= maxRows {
469- return rows
470- }
471 }504 }
472 }505 }
473 return rows506 return rows
M packages/agent/modes/timeline_view_test.go +40 -0
@@ -74,6 +74,46 @@ func TestBuildTimelineEntriesPairsToolDetails(t *testing.T) {
7474 }
7575 }
7676
77+func TestTimelineReasoningIsReadableAndScrollable(t *testing.T) {
78+ data := timelineTestData()
79+ data.Messages[1].Content = append([]provider.Content{provider.ReasoningBlock{
80+ Summary: "**First** step\n" + strings.Repeat("A long line of thought. ", 25) + "\nFinal insight",
81+ }}, data.Messages[1].Content...)
82+ entries := buildTimelineEntries(data)
83+ if len(entries) != 5 || entries[2].Kind != "reasoning" || entries[3].Kind != "assistant" {
84+ t.Fatalf("reasoning entries = %+v", entries)
85+ }
86+ if strings.Contains(entries[3].Summary, "First") {
87+ t.Fatalf("reasoning duplicated in assistant: %+v", entries[3])
88+ }
89+ view := newTimelineView()
90+ view.Open(data)
91+ view.cursor = 2
92+ rows := timelineDetailRows(tui.Dark, 40, entries[2], 0)
93+ if len(rows) < 4 || !strings.Contains(stripANSIBytes(strings.Join(rows, "\n")), "Final insight") {
94+ t.Fatalf("reasoning detail not complete: %q", rows)
95+ }
96+ for _, row := range rows {
97+ if runewidth.StringWidth(stripANSIBytes(row)) > 40 {
98+ t.Fatalf("row exceeds width: %q", row)
99+ }
100+ }
101+ first := strings.Join(view.Render(tui.Dark, 40, 17, data), "\n")
102+ view.HandleKey(tui.Key{Kind: tui.KeyEnd}, data)
103+ last := strings.Join(view.Render(tui.Dark, 40, 17, data), "\n")
104+ if view.detailTop == 0 || first == last || !strings.Contains(stripANSIBytes(last), "Final insight") {
105+ t.Fatalf("detail did not scroll to end: top=%d, output=%q", view.detailTop, last)
106+ }
107+ view.HandleKey(tui.Key{Kind: tui.KeyLeft}, data)
108+ if view.detailTop == 0 {
109+ t.Fatal("left did not scroll detail up")
110+ }
111+ view.HandleKey(tui.Key{Kind: tui.KeyTab}, data)
112+ if view.detailTop != 0 {
113+ t.Fatal("changing tab did not reset detail scroll")
114+ }
115+}
116+
77117 func TestTimelineNavigationKeysMoveSelection(t *testing.T) {
78118 data := timelineTestData()
79119 view := newTimelineView()
@@ -74,6 +74,46 @@ func TestBuildTimelineEntriesPairsToolDetails(t *testing.T) {
74 }74 }
75 }75 }
76 76
77+func TestTimelineReasoningIsReadableAndScrollable(t *testing.T) {
78+ data := timelineTestData()
79+ data.Messages[1].Content = append([]provider.Content{provider.ReasoningBlock{
80+ Summary: "**First** step\n" + strings.Repeat("A long line of thought. ", 25) + "\nFinal insight",
81+ }}, data.Messages[1].Content...)
82+ entries := buildTimelineEntries(data)
83+ if len(entries) != 5 || entries[2].Kind != "reasoning" || entries[3].Kind != "assistant" {
84+ t.Fatalf("reasoning entries = %+v", entries)
85+ }
86+ if strings.Contains(entries[3].Summary, "First") {
87+ t.Fatalf("reasoning duplicated in assistant: %+v", entries[3])
88+ }
89+ view := newTimelineView()
90+ view.Open(data)
91+ view.cursor = 2
92+ rows := timelineDetailRows(tui.Dark, 40, entries[2], 0)
93+ if len(rows) < 4 || !strings.Contains(stripANSIBytes(strings.Join(rows, "\n")), "Final insight") {
94+ t.Fatalf("reasoning detail not complete: %q", rows)
95+ }
96+ for _, row := range rows {
97+ if runewidth.StringWidth(stripANSIBytes(row)) > 40 {
98+ t.Fatalf("row exceeds width: %q", row)
99+ }
100+ }
101+ first := strings.Join(view.Render(tui.Dark, 40, 17, data), "\n")
102+ view.HandleKey(tui.Key{Kind: tui.KeyEnd}, data)
103+ last := strings.Join(view.Render(tui.Dark, 40, 17, data), "\n")
104+ if view.detailTop == 0 || first == last || !strings.Contains(stripANSIBytes(last), "Final insight") {
105+ t.Fatalf("detail did not scroll to end: top=%d, output=%q", view.detailTop, last)
106+ }
107+ view.HandleKey(tui.Key{Kind: tui.KeyLeft}, data)
108+ if view.detailTop == 0 {
109+ t.Fatal("left did not scroll detail up")
110+ }
111+ view.HandleKey(tui.Key{Kind: tui.KeyTab}, data)
112+ if view.detailTop != 0 {
113+ t.Fatal("changing tab did not reset detail scroll")
114+ }
115+}
116+
77 func TestTimelineNavigationKeysMoveSelection(t *testing.T) {117 func TestTimelineNavigationKeysMoveSelection(t *testing.T) {
78 data := timelineTestData()118 data := timelineTestData()
79 view := newTimelineView()119 view := newTimelineView()
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.go +3 -1
@@ -591,7 +591,9 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e
591591 }
592592 httpReq.Header.Set("content-type", "application/json")
593593 httpReq.Header.Set("accept", "text/event-stream")
594- httpReq.Header.Set("authorization", "Bearer "+c.apiKey)
594+ if c.apiKey != "" {
595+ httpReq.Header.Set("authorization", "Bearer "+c.apiKey)
596+ }
595597 for k, v := range c.headers {
596598 httpReq.Header.Set(k, v)
597599 }
@@ -591,7 +591,9 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e
591 }591 }
592 httpReq.Header.Set("content-type", "application/json")592 httpReq.Header.Set("content-type", "application/json")
593 httpReq.Header.Set("accept", "text/event-stream")593 httpReq.Header.Set("accept", "text/event-stream")
594- httpReq.Header.Set("authorization", "Bearer "+c.apiKey)594+ if c.apiKey != "" {
595+ httpReq.Header.Set("authorization", "Bearer "+c.apiKey)
596+ }
595 for k, v := range c.headers {597 for k, v := range c.headers {
596 httpReq.Header.Set(k, v)598 httpReq.Header.Set(k, v)
597 }599 }
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