Merge branch 'main' into feature/more-spinner-messages
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 | |||
| 382 | 382 | ||
| 383 | 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 | 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 | 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 | 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 | 653 | ### Kimi Code | |
| 654 | 654 | ||
| @@ -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 Code | 653 | ### Kimi Code | ||
| 654 | 654 | ||||
M docs/providers.md +13 -9
| @@ -470,11 +470,16 @@ unknown `api` value falls back to `openai` with a warning. | |||
| 470 | 470 | ``` | |
| 471 | 471 | ||
| 472 | 472 | 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. | |
| 478 | 483 | ||
| 479 | 484 | To retrieve this custom provider's key from a password manager, add a matching | |
| 480 | 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 | 553 | in `models.json`. The `/models` wire format is assumed to be OpenAI style | |
| 549 | 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. | |
| 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. | |
| 555 | 559 | ||
| 556 | 560 | ## Credential resolution | |
| 557 | 561 | ||
| @@ -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`, and | 472 | 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, so | 474 | + | 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 do | 475 | + | 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 stored | 476 | + | 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 matching | 484 | 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 style | 553 | 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 the | 557 | + | `--list-models` prints the catalog before the background refresh completes, |
| 553 | - | derived environment variable. `--list-models` prints the catalog before the | 558 | + | so discovered models may be missing there. |
| 554 | - | background refresh completes, so discovered models may be missing there. | |||
| 555 | 559 | ||||
| 556 | ## Credential resolution | 560 | ## Credential resolution | ||
| 557 | 561 | ||||
M packages/agent/build.go +63 -41
| @@ -66,14 +66,18 @@ type Resolved struct { | |||
| 66 | 66 | // Bookkeeping for MergeExtensionTools. Captured at Resolve time | |
| 67 | 67 | // so the system prompt can be rebuilt later without re-running | |
| 68 | 68 | // 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 | |
| 73 | 74 | } | |
| 74 | 75 | ||
| 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 | + | } | |
| 77 | 81 | ||
| 78 | 82 | // MergeExtensionTools folds every tool registered by an extension | |
| 79 | 83 | // 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 | 367 | accountID string | |
| 364 | 368 | 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 | 376 | cred = args.inheritedCredential | |
| 368 | 377 | method = args.inheritedAuthMethod | |
| 369 | 378 | accountID = args.inheritedAccountID | |
| @@ -376,7 +385,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 376 | 385 | ||
| 377 | 386 | // Persist --api-key for custom providers so subsequent runs don't | |
| 378 | 387 | // need to pass it again. | |
| 379 | - | if !isBuiltinProvider(provName) && args.APIKey != "" { | |
| 388 | + | if !isBuiltinProvider(provName) && !keylessCustom && args.APIKey != "" { | |
| 380 | 389 | if store := AuthStoreFor(); store != nil { | |
| 381 | 390 | _ = store.SetAPIKey(provName, args.APIKey) | |
| 382 | 391 | } | |
| @@ -594,13 +603,25 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 594 | 603 | ||
| 595 | 604 | // If the model has a base URL, credentials are optional (local | |
| 596 | 605 | // 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 | + | } | |
| 604 | 625 | } | |
| 605 | 626 | credErr = nil | |
| 606 | 627 | requireCred = false | |
| @@ -713,29 +734,30 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 713 | 734 | max := args.MaxSteps // 0 = unlimited | |
| 714 | 735 | ||
| 715 | 736 | 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), | |
| 739 | 761 | }, nil | |
| 740 | 762 | } | |
| 741 | 763 | ||
| @@ -844,11 +866,11 @@ func descMapFromSummaries(summaries []ToolSummary) map[string]string { | |||
| 844 | 866 | } | |
| 845 | 867 | ||
| 846 | 868 | // 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. | |
| 849 | 871 | func (r Resolved) NewClient() provider.Client { | |
| 850 | 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 | 875 | wrap := r.withHTTPClient | |
| 854 | 876 | switch r.Provider { | |
| @@ -66,14 +66,18 @@ type Resolved struct { | |||||
| 66 | // Bookkeeping for MergeExtensionTools. Captured at Resolve time | 66 | // Bookkeeping for MergeExtensionTools. Captured at Resolve time | ||
| 67 | // so the system prompt can be rebuilt later without re-running | 67 | // so the system prompt can be rebuilt later without re-running | ||
| 68 | // resolve. | 68 | // resolve. | ||
| 69 | - | systemAppend []string | 69 | + | credentialOptional bool |
| 70 | - | systemCustom string | 70 | + | systemAppend []string |
| 71 | - | systemCustomSet bool | 71 | + | systemCustom string |
| 72 | - | toolDescriptions map[string]string | 72 | + | 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 extension | 82 | // MergeExtensionTools folds every tool registered by an extension | ||
| 79 | // into r's ToolRegistry and re-renders the system prompt's tool | 83 | // 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 string | 367 | accountID string | ||
| 364 | credErr error | 368 | 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.inheritedCredential | 376 | cred = args.inheritedCredential | ||
| 368 | method = args.inheritedAuthMethod | 377 | method = args.inheritedAuthMethod | ||
| 369 | accountID = args.inheritedAccountID | 378 | 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't | 386 | // 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 (local | 604 | // 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-model | 610 | + | } |
| 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 = nil | 626 | credErr = nil | ||
| 606 | requireCred = false | 627 | requireCred = false | ||
| @@ -713,29 +734,30 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 713 | max := args.MaxSteps // 0 = unlimited | 734 | 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 | }, nil | 761 | }, 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 mode | 868 | // NewClient returns a provider.Client for r, choosing the auth mode | ||
| 847 | - | // based on r.AuthMethod. Panics if no credential is present; callers | 869 | + | // 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.withHTTPClient | 875 | wrap := r.withHTTPClient | ||
| 854 | switch r.Provider { | 876 | switch r.Provider { | ||
M packages/agent/build_test.go +137 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "net/http/httptest" | |
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | + | "slices" | |
| 9 | 10 | "strings" | |
| 10 | 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 | 709 | func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) { | |
| 573 | 710 | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 574 | 711 | 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 { | |||
| 1437 | 1437 | SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled, | |
| 1438 | 1438 | BuildAgentFor: buildAgentFor, | |
| 1439 | 1439 | 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, | |
| 1465 | 1444 | CurrentSessionPath: func() string { | |
| 1466 | 1445 | if sess == nil { | |
| 1467 | 1446 | 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 []string | 1441 | + | 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 { | |||
| 43 | 43 | // unauthenticated requests. skip is true when the only key is command-backed | |
| 44 | 44 | // and mode forbids running the command. | |
| 45 | 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 | 49 | if v := os.Getenv(normalizeCustomProviderEnvVar(name) + "_API_KEY"); v != "" { | |
| 47 | 50 | return v, false, nil | |
| 48 | 51 | } | |
| @@ -43,6 +43,9 @@ func CustomDiscoveryConfigured() bool { | |||||
| 43 | // unauthenticated requests. skip is true when the only key is command-backed | 43 | // 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, nil | 50 | return v, false, nil | ||
| 48 | } | 51 | } | ||
M packages/agent/customdiscovery_test.go +17 -0
| @@ -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 | 156 | func TestRefreshCustomProviderModelsBackgroundSkipsKeyCommand(t *testing.T) { | |
| 140 | 157 | home := t.TempDir() | |
| 141 | 158 | 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 { | |||
| 44 | 44 | active bool | |
| 45 | 45 | cursor int | |
| 46 | 46 | tab int | |
| 47 | + | detailTop int | |
| 47 | 48 | searching bool | |
| 48 | 49 | filter string | |
| 49 | 50 | notice string | |
| @@ -64,6 +65,7 @@ func (v *timelineView) Open(data timelineData) { | |||
| 64 | 65 | v.filter = "" | |
| 65 | 66 | v.notice = "" | |
| 66 | 67 | v.tab = 0 | |
| 68 | + | v.detailTop = 0 | |
| 67 | 69 | v.cursor = len(buildTimelineEntries(data)) - 1 | |
| 68 | 70 | if v.cursor < 0 { | |
| 69 | 71 | v.cursor = 0 | |
| @@ -75,6 +77,7 @@ func (v *timelineView) Close() { | |||
| 75 | 77 | v.searching = false | |
| 76 | 78 | v.filter = "" | |
| 77 | 79 | v.notice = "" | |
| 80 | + | v.detailTop = 0 | |
| 78 | 81 | } | |
| 79 | 82 | ||
| 80 | 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 | 86 | entries := filterTimelineEntries(buildTimelineEntries(data), v.filter) | |
| 84 | 87 | clampTimelineCursor(v, len(entries)) | |
| 85 | 88 | ||
| 89 | + | previousCursor, previousTab := v.cursor, v.tab | |
| 86 | 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 | 101 | case tui.KeyUp: | |
| 88 | 102 | if v.cursor > 0 { | |
| 89 | 103 | v.cursor-- | |
| @@ -111,11 +125,13 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction { | |||
| 111 | 125 | r := []rune(v.filter) | |
| 112 | 126 | v.filter = string(r[:len(r)-1]) | |
| 113 | 127 | v.cursor = 0 | |
| 128 | + | v.detailTop = 0 | |
| 114 | 129 | } | |
| 115 | 130 | case tui.KeyPaste: | |
| 116 | 131 | if v.searching { | |
| 117 | 132 | v.filter += singleLinePaste(k.Paste) | |
| 118 | 133 | v.cursor = 0 | |
| 134 | + | v.detailTop = 0 | |
| 119 | 135 | } | |
| 120 | 136 | case tui.KeyRune: | |
| 121 | 137 | if !v.searching && k.Rune == '/' { | |
| @@ -124,12 +140,14 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction { | |||
| 124 | 140 | } else if v.searching && !k.Ctrl && !k.Alt && !k.Super { | |
| 125 | 141 | v.filter += string(k.Rune) | |
| 126 | 142 | v.cursor = 0 | |
| 143 | + | v.detailTop = 0 | |
| 127 | 144 | } | |
| 128 | 145 | case tui.KeyEsc, tui.KeyCtrlC: | |
| 129 | 146 | if v.searching { | |
| 130 | 147 | if v.filter != "" { | |
| 131 | 148 | v.filter = "" | |
| 132 | 149 | v.cursor = 0 | |
| 150 | + | v.detailTop = 0 | |
| 133 | 151 | } else { | |
| 134 | 152 | v.searching = false | |
| 135 | 153 | } | |
| @@ -138,6 +156,9 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction { | |||
| 138 | 156 | v.Close() | |
| 139 | 157 | return timelineAction{Close: true} | |
| 140 | 158 | } | |
| 159 | + | if v.cursor != previousCursor || v.tab != previousTab { | |
| 160 | + | v.detailTop = 0 | |
| 161 | + | } | |
| 141 | 162 | return timelineAction{} | |
| 142 | 163 | } | |
| 143 | 164 | ||
| @@ -167,7 +188,7 @@ func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData | |||
| 167 | 188 | lines := []string{frameHeader(th, "timeline", width)} | |
| 168 | 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 | 192 | if v.searching { | |
| 172 | 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 | 232 | if detailRows < 3 { | |
| 212 | 233 | 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 | 245 | if v.notice != "" { | |
| 217 | 246 | lines = append(lines, th.FG256(th.Tool, fitTimelineLine(v.notice, width))) | |
| @@ -249,6 +278,14 @@ func buildTimelineEntries(data timelineData) []timelineEntry { | |||
| 249 | 278 | if message.Role == provider.RoleUser { | |
| 250 | 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 | 289 | textParts := timelineMessageText(message) | |
| 253 | 290 | if len(textParts) > 0 { | |
| 254 | 291 | entry := timelineEntry{ | |
| @@ -294,10 +331,6 @@ func timelineMessageText(message provider.Message) []string { | |||
| 294 | 331 | if strings.TrimSpace(block.Text) != "" { | |
| 295 | 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 | 334 | case provider.ImageBlock: | |
| 302 | 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 | 416 | switch entry.Kind { | |
| 384 | 417 | case "user": | |
| 385 | 418 | color = th.User | |
| 386 | - | case "assistant": | |
| 419 | + | case "assistant", "reasoning": | |
| 387 | 420 | color = th.Assistant | |
| 388 | 421 | case "tool": | |
| 389 | 422 | color = th.Tool | |
| @@ -436,7 +469,7 @@ func timelineTabsWidth(start, end int) int { | |||
| 436 | 469 | 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 | 473 | var detail string | |
| 441 | 474 | switch tab { | |
| 442 | 475 | case 0: | |
| @@ -461,13 +494,13 @@ func renderTimelineDetail(th tui.Theme, width int, entry timelineEntry, tab, max | |||
| 461 | 494 | if strings.TrimSpace(detail) == "" { | |
| 462 | 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 | 500 | var rows []string | |
| 465 | 501 | for _, line := range strings.Split(detail, "\n") { | |
| 466 | 502 | for _, wrapped := range tui.WrapANSILine(line, width-2) { | |
| 467 | 503 | rows = append(rows, " "+th.FG256(th.ToolOut, wrapped)) | |
| 468 | - | if len(rows) >= maxRows { | |
| 469 | - | return rows | |
| 470 | - | } | |
| 471 | 504 | } | |
| 472 | 505 | } | |
| 473 | 506 | return rows | |
| @@ -44,6 +44,7 @@ type timelineView struct { | |||||
| 44 | active bool | 44 | active bool | ||
| 45 | cursor int | 45 | cursor int | ||
| 46 | tab int | 46 | tab int | ||
| 47 | + | detailTop int | |||
| 47 | searching bool | 48 | searching bool | ||
| 48 | filter string | 49 | filter string | ||
| 49 | notice string | 50 | 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 = 0 | 67 | v.tab = 0 | ||
| 68 | + | v.detailTop = 0 | |||
| 67 | v.cursor = len(buildTimelineEntries(data)) - 1 | 69 | v.cursor = len(buildTimelineEntries(data)) - 1 | ||
| 68 | if v.cursor < 0 { | 70 | if v.cursor < 0 { | ||
| 69 | v.cursor = 0 | 71 | v.cursor = 0 | ||
| @@ -75,6 +77,7 @@ func (v *timelineView) Close() { | |||||
| 75 | v.searching = false | 77 | 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 = 0 | 127 | 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 = 0 | 133 | 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 = 0 | 142 | 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 = 0 | 149 | v.cursor = 0 | ||
| 150 | + | v.detailTop = 0 | |||
| 133 | } else { | 151 | } else { | ||
| 134 | v.searching = false | 152 | 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 = 3 | 233 | 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.User | 418 | color = th.User | ||
| 386 | - | case "assistant": | 419 | + | case "assistant", "reasoning": |
| 387 | color = th.Assistant | 420 | color = th.Assistant | ||
| 388 | case "tool": | 421 | case "tool": | ||
| 389 | color = th.Tool | 422 | color = th.Tool | ||
| @@ -436,7 +469,7 @@ func timelineTabsWidth(start, end int) int { | |||||
| 436 | return width | 469 | 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 string | 473 | 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 []string | 500 | 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 rows | 506 | return rows | ||
M packages/agent/modes/timeline_view_test.go +40 -0
| @@ -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 | 117 | func TestTimelineNavigationKeysMoveSelection(t *testing.T) { | |
| 78 | 118 | data := timelineTestData() | |
| 79 | 119 | 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 | |||
| 564 | 564 | // Remove x-api-key entirely by NOT setting it. | |
| 565 | 565 | } else { | |
| 566 | 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 | 571 | // Extra headers (set by anthropic-messages-compatible third parties | |
| 570 | 572 | // — 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 parties | 571 | // 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 | |||
| 591 | 591 | } | |
| 592 | 592 | httpReq.Header.Set("content-type", "application/json") | |
| 593 | 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 | 597 | for k, v := range c.headers { | |
| 596 | 598 | httpReq.Header.Set(k, v) | |
| 597 | 599 | } | |
| @@ -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, | |||
| 38 | 38 | clone.Header.Del("chatgpt-account-id") | |
| 39 | 39 | clone.Header.Del("openai-beta") | |
| 40 | 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 | 46 | return t.inner.RoundTrip(clone) | |
| 43 | 47 | } | |
| 44 | 48 | ||
| @@ -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 { | |||
| 37 | 37 | // UserProvider groups models under a provider key. | |
| 38 | 38 | type UserProvider struct { | |
| 39 | 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 | 42 | // Discover enables live model listing from the provider-level | |
| 42 | 43 | // baseUrl's /models endpoint. Off by default so existing custom | |
| 43 | 44 | // providers never gain unexpected network traffic. | |
| @@ -50,6 +51,7 @@ type UserProvider struct { | |||
| 50 | 51 | type CustomProviderConfig struct { | |
| 51 | 52 | BaseURL string | |
| 52 | 53 | API string // "openai", "openai-responses", or "anthropic" | |
| 54 | + | NoAuth bool // explicitly configured for keyless access | |
| 53 | 55 | // Discover reports whether live model discovery is enabled. It is | |
| 54 | 56 | // only true when a provider-level BaseURL is present. | |
| 55 | 57 | Discover bool | |
| @@ -172,7 +174,17 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) { | |||
| 172 | 174 | warnings = append(warnings, fmt.Sprintf("models.json: provider %q sets discover without a provider-level baseUrl; discovery disabled", providerName)) | |
| 173 | 175 | 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 | 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 | 188 | api := strings.ToLower(strings.TrimSpace(prov.API)) | |
| 177 | 189 | if api == "" { | |
| 178 | 190 | api = "openai" | |
| @@ -192,6 +204,7 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) { | |||
| 192 | 204 | customProviders[normalized] = CustomProviderConfig{ | |
| 193 | 205 | BaseURL: prov.BaseURL, | |
| 194 | 206 | API: api, | |
| 207 | + | NoAuth: auth == "none", | |
| 195 | 208 | Discover: discover, | |
| 196 | 209 | } | |
| 197 | 210 | } | |
| @@ -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-level | 42 | // Discover enables live model listing from the provider-level | ||
| 42 | // baseUrl's /models endpoint. Off by default so existing custom | 43 | // 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 string | 52 | 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 is | 55 | // 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 bool | 57 | 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 = false | 175 | 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) { | |||
| 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 | 190 | func TestLoadUserModelsDiscoverFlag(t *testing.T) { | |
| 173 | 191 | dir := t.TempDir() | |
| 174 | 192 | 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") | ||