detached 6bf1bb1

Add Kimi K3 deferred tools and xAI OAuth

commit
6bf1bb11124ca1ee7039bc8934e40d35cae31c68
author
patriceckhart · · 2026-07-17 15:37 UTC
parent
574518c
signature
unsigned
27 files · +736 -47
M README.md +12 -5
@@ -90,18 +90,19 @@ The easiest way is to just run `zot` and type `/login`. The TUI opens even witho
9090 Run `zot` and type `/login`. Pick one of two methods:
9191
9292 - **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you pick a provider from the full API-key provider list, paste the key, and zot saves it to `auth.json` if accepted. Providers with a lightweight model-list endpoint are probed before saving; provider backends that need extra project/account env vars are saved directly.
93-- **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, Kimi Code, or GitHub Copilot subscription. DeepSeek and Google Gemini do **not** have a subscription login path. For those, use the API-key flow.
93+- **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, Kimi Code, SuperGrok/X Premium, or GitHub Copilot subscription. DeepSeek and Google Gemini do **not** have a subscription login path. For those, use the API-key flow.
9494 - Anthropic and OpenAI pin the browser callback to fixed provider-specific ports (`localhost:53692` for Anthropic, `localhost:1455` for OpenAI) because those are the only ports their auth servers will redirect to.
9595 - Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers.
9696 - OpenAI uses the Codex CLI OAuth flow. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token.
9797 - Kimi uses the Kimi Code device-code OAuth flow. zot opens the verification URL, polls until you approve it in the browser, then sends messages to `api.kimi.com/coding/v1` with the Kimi Code identity headers.
98+ - xAI uses a device-code OAuth flow. zot opens a prefilled authorization URL, polls for approval, and uses the resulting token with the xAI API.
9899 - GitHub Copilot uses GitHub's device-code login flow. zot stores the GitHub access token and exchanges it for short-lived Copilot inference tokens on demand.
99100
100-> **Note on subscription login.** The OAuth client IDs used are the ones published in Anthropic's Claude Code CLI, OpenAI's Codex CLI, Kimi Code CLI, and GitHub Copilot's device-code flow. Reusing them from a third-party tool may be against their terms of service and may be revoked at any time. Use it at your own risk; the API-key flow is the safe default.
101+> **Note on subscription login.** The OAuth client IDs used are the ones published in Anthropic's Claude Code CLI, OpenAI's Codex CLI, Kimi Code CLI, xAI's device flow, and GitHub Copilot's device-code flow. Reusing them from a third-party tool may be against their terms of service and may be revoked at any time. Use it at your own risk; the API-key flow is the safe default.
101102
102103 ### Token refresh
103104
104-OAuth access tokens are short-lived (Anthropic ~8h, OpenAI ~30d; Kimi and GitHub Copilot also use refresh/exchange flows). zot refreshes or exchanges them automatically:
105+OAuth access tokens are short-lived (Anthropic ~8h, OpenAI ~30d; Kimi, xAI, and GitHub Copilot also use refresh/exchange flows). zot refreshes or exchanges them automatically:
105106
106107 - At every credential lookup, zot checks the stored `expiry` and, if past it (with a 60s safety margin), hits the provider's `oauth/token` endpoint with the stored `refresh_token`, persists the new `access_token`, `refresh_token`, and `expiry` back to `auth.json`, and hands the fresh token to the client.
107108 - The telegram bridge additionally refreshes once per turn so a bot that runs for days keeps working without manual intervention.
@@ -382,7 +383,7 @@ Every interactive or print/json run (unless `--no-session`) writes a JSONL trans
382383
383384 zot's built-in provider catalog includes:
384385
385-- **Subscription-capable**: Anthropic Claude Pro/Max (`anthropic`), OpenAI Codex / ChatGPT Plus/Pro (`openai-codex`), Kimi Code (`kimi`), GitHub Copilot (`github-copilot`).
386+- **Subscription-capable**: Anthropic Claude Pro/Max (`anthropic`), OpenAI Codex / ChatGPT Plus/Pro (`openai-codex`), Kimi Code (`kimi`), SuperGrok/X Premium (`xai`), GitHub Copilot (`github-copilot`).
386387 - **Direct API providers**: Anthropic, OpenAI Chat Completions, OpenAI Responses, DeepSeek, Google Gemini, Kimi/Moonshot, Moonshot CN, Groq, Cerebras, xAI, Together AI, Hugging Face Router, OpenRouter, Mistral, Z.AI, Xiaomi/MiMo token-plan regions, MiniMax global/CN, Fireworks, Vercel AI Gateway, OpenCode/OpenCode Go.
387388 - **Cloud/platform providers**: Amazon Bedrock, Google Vertex AI, Azure OpenAI, Cloudflare Workers AI, Cloudflare AI Gateway.
388389 - **Local/compatible**: Ollama and OpenAI-compatible local endpoints via `--base-url`.
@@ -461,7 +462,7 @@ Custom providers are first-class: they appear in `--list-models`, `/model`, and
461462
462463 ### Kimi Code
463464
464-zot has built-in Kimi support through Kimi's OpenAI-compatible chat API.
465+zot has built-in Kimi support through the Kimi Coding endpoint and Moonshot's OpenAI-compatible chat API.
465466
466467 ```bash
467468 zot --provider kimi
@@ -488,8 +489,14 @@ For direct Moonshot API keys or a custom compatible endpoint:
488489 zot --provider kimi --model kimi-k2-0905-preview --base-url https://api.moonshot.ai/v1 --api-key "$KIMI_API_KEY"
489490 ```
490491
492+Kimi K3 is built in as `kimi/k3`, `moonshotai/kimi-k3`, `moonshotai-cn/kimi-k3`, `openrouter/moonshotai/kimi-k3`, and `vercel-ai-gateway/moonshotai/kimi-k3`. Its output limit is 131,072 tokens on every built-in route.
493+
491494 You can add additional Kimi/Moonshot model IDs to `models.json` under the `kimi` provider.
492495
496+### xAI
497+
498+xAI supports either `XAI_API_KEY` or `/login` subscription authentication. The subscription option is labeled `Sign in with SuperGrok or X Premium`, opens a prefilled device-authorization URL, and refreshes the stored token automatically. The default xAI model is `grok-4.5`, sent through the Responses API.
499+
493500 ### DeepSeek
494501
495502 zot has built-in DeepSeek support through DeepSeek's OpenAI-compatible chat API.
@@ -90,18 +90,19 @@ The easiest way is to just run `zot` and type `/login`. The TUI opens even witho
90 Run `zot` and type `/login`. Pick one of two methods:90 Run `zot` and type `/login`. Pick one of two methods:
91 91
92 - **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you pick a provider from the full API-key provider list, paste the key, and zot saves it to `auth.json` if accepted. Providers with a lightweight model-list endpoint are probed before saving; provider backends that need extra project/account env vars are saved directly.92 - **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you pick a provider from the full API-key provider list, paste the key, and zot saves it to `auth.json` if accepted. Providers with a lightweight model-list endpoint are probed before saving; provider backends that need extra project/account env vars are saved directly.
93-- **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, Kimi Code, or GitHub Copilot subscription. DeepSeek and Google Gemini do **not** have a subscription login path. For those, use the API-key flow.93+- **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, Kimi Code, SuperGrok/X Premium, or GitHub Copilot subscription. DeepSeek and Google Gemini do **not** have a subscription login path. For those, use the API-key flow.
94 - Anthropic and OpenAI pin the browser callback to fixed provider-specific ports (`localhost:53692` for Anthropic, `localhost:1455` for OpenAI) because those are the only ports their auth servers will redirect to.94 - Anthropic and OpenAI pin the browser callback to fixed provider-specific ports (`localhost:53692` for Anthropic, `localhost:1455` for OpenAI) because those are the only ports their auth servers will redirect to.
95 - Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers.95 - Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers.
96 - OpenAI uses the Codex CLI OAuth flow. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token.96 - OpenAI uses the Codex CLI OAuth flow. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token.
97 - Kimi uses the Kimi Code device-code OAuth flow. zot opens the verification URL, polls until you approve it in the browser, then sends messages to `api.kimi.com/coding/v1` with the Kimi Code identity headers.97 - Kimi uses the Kimi Code device-code OAuth flow. zot opens the verification URL, polls until you approve it in the browser, then sends messages to `api.kimi.com/coding/v1` with the Kimi Code identity headers.
98+ - xAI uses a device-code OAuth flow. zot opens a prefilled authorization URL, polls for approval, and uses the resulting token with the xAI API.
98 - GitHub Copilot uses GitHub's device-code login flow. zot stores the GitHub access token and exchanges it for short-lived Copilot inference tokens on demand.99 - GitHub Copilot uses GitHub's device-code login flow. zot stores the GitHub access token and exchanges it for short-lived Copilot inference tokens on demand.
99 100
100-> **Note on subscription login.** The OAuth client IDs used are the ones published in Anthropic's Claude Code CLI, OpenAI's Codex CLI, Kimi Code CLI, and GitHub Copilot's device-code flow. Reusing them from a third-party tool may be against their terms of service and may be revoked at any time. Use it at your own risk; the API-key flow is the safe default.101+> **Note on subscription login.** The OAuth client IDs used are the ones published in Anthropic's Claude Code CLI, OpenAI's Codex CLI, Kimi Code CLI, xAI's device flow, and GitHub Copilot's device-code flow. Reusing them from a third-party tool may be against their terms of service and may be revoked at any time. Use it at your own risk; the API-key flow is the safe default.
101 102
102 ### Token refresh103 ### Token refresh
103 104
104-OAuth access tokens are short-lived (Anthropic ~8h, OpenAI ~30d; Kimi and GitHub Copilot also use refresh/exchange flows). zot refreshes or exchanges them automatically:105+OAuth access tokens are short-lived (Anthropic ~8h, OpenAI ~30d; Kimi, xAI, and GitHub Copilot also use refresh/exchange flows). zot refreshes or exchanges them automatically:
105 106
106 - At every credential lookup, zot checks the stored `expiry` and, if past it (with a 60s safety margin), hits the provider's `oauth/token` endpoint with the stored `refresh_token`, persists the new `access_token`, `refresh_token`, and `expiry` back to `auth.json`, and hands the fresh token to the client.107 - At every credential lookup, zot checks the stored `expiry` and, if past it (with a 60s safety margin), hits the provider's `oauth/token` endpoint with the stored `refresh_token`, persists the new `access_token`, `refresh_token`, and `expiry` back to `auth.json`, and hands the fresh token to the client.
107 - The telegram bridge additionally refreshes once per turn so a bot that runs for days keeps working without manual intervention.108 - The telegram bridge additionally refreshes once per turn so a bot that runs for days keeps working without manual intervention.
@@ -382,7 +383,7 @@ Every interactive or print/json run (unless `--no-session`) writes a JSONL trans
382 383
383 zot's built-in provider catalog includes:384 zot's built-in provider catalog includes:
384 385
385-- **Subscription-capable**: Anthropic Claude Pro/Max (`anthropic`), OpenAI Codex / ChatGPT Plus/Pro (`openai-codex`), Kimi Code (`kimi`), GitHub Copilot (`github-copilot`).386+- **Subscription-capable**: Anthropic Claude Pro/Max (`anthropic`), OpenAI Codex / ChatGPT Plus/Pro (`openai-codex`), Kimi Code (`kimi`), SuperGrok/X Premium (`xai`), GitHub Copilot (`github-copilot`).
386 - **Direct API providers**: Anthropic, OpenAI Chat Completions, OpenAI Responses, DeepSeek, Google Gemini, Kimi/Moonshot, Moonshot CN, Groq, Cerebras, xAI, Together AI, Hugging Face Router, OpenRouter, Mistral, Z.AI, Xiaomi/MiMo token-plan regions, MiniMax global/CN, Fireworks, Vercel AI Gateway, OpenCode/OpenCode Go.387 - **Direct API providers**: Anthropic, OpenAI Chat Completions, OpenAI Responses, DeepSeek, Google Gemini, Kimi/Moonshot, Moonshot CN, Groq, Cerebras, xAI, Together AI, Hugging Face Router, OpenRouter, Mistral, Z.AI, Xiaomi/MiMo token-plan regions, MiniMax global/CN, Fireworks, Vercel AI Gateway, OpenCode/OpenCode Go.
387 - **Cloud/platform providers**: Amazon Bedrock, Google Vertex AI, Azure OpenAI, Cloudflare Workers AI, Cloudflare AI Gateway.388 - **Cloud/platform providers**: Amazon Bedrock, Google Vertex AI, Azure OpenAI, Cloudflare Workers AI, Cloudflare AI Gateway.
388 - **Local/compatible**: Ollama and OpenAI-compatible local endpoints via `--base-url`.389 - **Local/compatible**: Ollama and OpenAI-compatible local endpoints via `--base-url`.
@@ -461,7 +462,7 @@ Custom providers are first-class: they appear in `--list-models`, `/model`, and
461 462
462 ### Kimi Code463 ### Kimi Code
463 464
464-zot has built-in Kimi support through Kimi's OpenAI-compatible chat API.465+zot has built-in Kimi support through the Kimi Coding endpoint and Moonshot's OpenAI-compatible chat API.
465 466
466 ```bash467 ```bash
467 zot --provider kimi468 zot --provider kimi
@@ -488,8 +489,14 @@ For direct Moonshot API keys or a custom compatible endpoint:
488 zot --provider kimi --model kimi-k2-0905-preview --base-url https://api.moonshot.ai/v1 --api-key "$KIMI_API_KEY"489 zot --provider kimi --model kimi-k2-0905-preview --base-url https://api.moonshot.ai/v1 --api-key "$KIMI_API_KEY"
489 ```490 ```
490 491
492+Kimi K3 is built in as `kimi/k3`, `moonshotai/kimi-k3`, `moonshotai-cn/kimi-k3`, `openrouter/moonshotai/kimi-k3`, and `vercel-ai-gateway/moonshotai/kimi-k3`. Its output limit is 131,072 tokens on every built-in route.
493+
491 You can add additional Kimi/Moonshot model IDs to `models.json` under the `kimi` provider.494 You can add additional Kimi/Moonshot model IDs to `models.json` under the `kimi` provider.
492 495
496+### xAI
497+
498+xAI supports either `XAI_API_KEY` or `/login` subscription authentication. The subscription option is labeled `Sign in with SuperGrok or X Premium`, opens a prefilled device-authorization URL, and refreshes the stored token automatically. The default xAI model is `grok-4.5`, sent through the Responses API.
499+
493 ### DeepSeek500 ### DeepSeek
494 501
495 zot has built-in DeepSeek support through DeepSeek's OpenAI-compatible chat API.502 zot has built-in DeepSeek support through DeepSeek's OpenAI-compatible chat API.
M docs/extensions.md +12 -1
@@ -204,6 +204,16 @@ Tool names live in the same namespace as built-in tools (`read`,
204204 `write`, `edit`, `bash`, `skill`). Conflicts are silently shadowed by
205205 the built-in.
206206
207+Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:
208+
209+```json
210+{"type":"tool_result","id":"...",
211+ "content":[{"type":"text","text":"Enabled weather lookup"}],
212+ "activate_tools":["weather"]}
213+```
214+
215+On Kimi K3's OpenAI-compatible routes, zot places newly activated schemas at the tool-result position using Kimi's native deferred-tool format. Other models receive the complete active tool list on the next request. Unknown names are ignored. The Go extension SDK exposes `DeferredTool` and `ToolResult.ActivateTools` for the same protocol.
216+
207217 #### `ready`
208218
209219 Sentinel telling zot "all initial registrations are flushed". Send it
@@ -219,7 +229,8 @@ agent's tool registry without racing the registration window.
219229 Reply to a `tool_call` from the host. `content[]` is a list of
220230 message blocks; each block is `{"type":"text","text":"..."}` or
221231 `{"type":"image","mime_type":"image/png","data":"<base64>"}`. Set
222-`is_error: true` to mark the call as failed.
232+`is_error: true` to mark the call as failed. `activate_tools` can name
233+registered deferred tools that become available after this result.
223234
224235 ```json
225236 {"type":"tool_result","id":"...",
@@ -204,6 +204,16 @@ Tool names live in the same namespace as built-in tools (`read`,
204 `write`, `edit`, `bash`, `skill`). Conflicts are silently shadowed by204 `write`, `edit`, `bash`, `skill`). Conflicts are silently shadowed by
205 the built-in.205 the built-in.
206 206
207+Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:
208+
209+```json
210+{"type":"tool_result","id":"...",
211+ "content":[{"type":"text","text":"Enabled weather lookup"}],
212+ "activate_tools":["weather"]}
213+```
214+
215+On Kimi K3's OpenAI-compatible routes, zot places newly activated schemas at the tool-result position using Kimi's native deferred-tool format. Other models receive the complete active tool list on the next request. Unknown names are ignored. The Go extension SDK exposes `DeferredTool` and `ToolResult.ActivateTools` for the same protocol.
216+
207 #### `ready`217 #### `ready`
208 218
209 Sentinel telling zot "all initial registrations are flushed". Send it219 Sentinel telling zot "all initial registrations are flushed". Send it
@@ -219,7 +229,8 @@ agent's tool registry without racing the registration window.
219 Reply to a `tool_call` from the host. `content[]` is a list of229 Reply to a `tool_call` from the host. `content[]` is a list of
220 message blocks; each block is `{"type":"text","text":"..."}` or230 message blocks; each block is `{"type":"text","text":"..."}` or
221 `{"type":"image","mime_type":"image/png","data":"<base64>"}`. Set231 `{"type":"image","mime_type":"image/png","data":"<base64>"}`. Set
222-`is_error: true` to mark the call as failed.232+`is_error: true` to mark the call as failed. `activate_tools` can name
233+registered deferred tools that become available after this result.
223 234
224 ```json235 ```json
225 {"type":"tool_result","id":"...",236 {"type":"tool_result","id":"...",
M docs/providers.md +1 -0
@@ -35,6 +35,7 @@ These providers support subscription login:
3535 | Anthropic | Claude Pro/Max OAuth credentials. |
3636 | OpenAI Codex | ChatGPT Plus/Pro Codex subscription route. Separate from the OpenAI API-key provider. |
3737 | Kimi | Kimi subscription login. |
38+| xAI | SuperGrok or X Premium device-code login. The browser URL is prefilled with the device code. |
3839 | GitHub Copilot | GitHub Copilot token flow. |
3940
4041 OAuth tokens are stored in `$ZOT_HOME/auth.json` and refreshed when refresh is
@@ -35,6 +35,7 @@ These providers support subscription login:
35 | Anthropic | Claude Pro/Max OAuth credentials. |35 | Anthropic | Claude Pro/Max OAuth credentials. |
36 | OpenAI Codex | ChatGPT Plus/Pro Codex subscription route. Separate from the OpenAI API-key provider. |36 | OpenAI Codex | ChatGPT Plus/Pro Codex subscription route. Separate from the OpenAI API-key provider. |
37 | Kimi | Kimi subscription login. |37 | Kimi | Kimi subscription login. |
38+| xAI | SuperGrok or X Premium device-code login. The browser URL is prefilled with the device code. |
38 | GitHub Copilot | GitHub Copilot token flow. |39 | GitHub Copilot | GitHub Copilot token flow. |
39 40
40 OAuth tokens are stored in `$ZOT_HOME/auth.json` and refreshed when refresh is41 OAuth tokens are stored in `$ZOT_HOME/auth.json` and refreshed when refresh is
M packages/agent/build.go +20 -2
@@ -112,6 +112,7 @@ type ExtensionToolInfo struct {
112112 Name string
113113 Description string
114114 Schema []byte
115+ Deferred bool
115116 }
116117
117118 // toolSummariesFromRegistry rebuilds the system-prompt tool list
@@ -120,6 +121,9 @@ type ExtensionToolInfo struct {
120121 func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {
121122 out := make([]ToolSummary, 0, len(reg))
122123 for name, t := range reg {
124+ if d, ok := t.(interface{ Deferred() bool }); ok && d.Deferred() {
125+ continue
126+ }
123127 desc := t.Description()
124128 if d, ok := cached[name]; ok && d != "" {
125129 desc = d
@@ -160,7 +164,7 @@ func defaultModelForProvider(prov string) string {
160164 case "groq":
161165 return "llama-3.3-70b-versatile"
162166 case "xai":
163- return "grok-code-fast-1"
167+ return "grok-4.5"
164168 case "together":
165169 return "Qwen/Qwen3-Coder-480B-A35B-Instruct"
166170 case "huggingface":
@@ -773,7 +777,16 @@ func (r Resolved) NewClient() provider.Client {
773777 case "groq":
774778 return wrap(provider.NewGroq(r.Credential, r.BaseURL))
775779 case "xai":
776- return wrap(provider.NewXAI(r.Credential, r.BaseURL))
780+ var inner provider.Client
781+ if r.Model == "grok-4.5" {
782+ inner = wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "xai"))
783+ } else {
784+ inner = wrap(provider.NewXAI(r.Credential, r.BaseURL))
785+ }
786+ if r.AuthMethod == "oauth" {
787+ return r.wrapWithRefresh(inner)
788+ }
789+ return inner
777790 case "together":
778791 return wrap(provider.NewTogether(r.Credential, r.BaseURL))
779792 case "huggingface":
@@ -868,6 +881,11 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
868881 case "kimi":
869882 // anthropic-messages on api.kimi.com/coding.
870883 return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
884+ case "xai":
885+ if r.Model == "grok-4.5" {
886+ return r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, baseURL, "xai"))
887+ }
888+ return r.withHTTPClient(provider.NewXAI(token, baseURL))
871889 default:
872890 return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
873891 }
@@ -112,6 +112,7 @@ type ExtensionToolInfo struct {
112 Name string112 Name string
113 Description string113 Description string
114 Schema []byte114 Schema []byte
115+ Deferred bool
115 }116 }
116 117
117 // toolSummariesFromRegistry rebuilds the system-prompt tool list118 // toolSummariesFromRegistry rebuilds the system-prompt tool list
@@ -120,6 +121,9 @@ type ExtensionToolInfo struct {
120 func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {121 func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {
121 out := make([]ToolSummary, 0, len(reg))122 out := make([]ToolSummary, 0, len(reg))
122 for name, t := range reg {123 for name, t := range reg {
124+ if d, ok := t.(interface{ Deferred() bool }); ok && d.Deferred() {
125+ continue
126+ }
123 desc := t.Description()127 desc := t.Description()
124 if d, ok := cached[name]; ok && d != "" {128 if d, ok := cached[name]; ok && d != "" {
125 desc = d129 desc = d
@@ -160,7 +164,7 @@ func defaultModelForProvider(prov string) string {
160 case "groq":164 case "groq":
161 return "llama-3.3-70b-versatile"165 return "llama-3.3-70b-versatile"
162 case "xai":166 case "xai":
163- return "grok-code-fast-1"167+ return "grok-4.5"
164 case "together":168 case "together":
165 return "Qwen/Qwen3-Coder-480B-A35B-Instruct"169 return "Qwen/Qwen3-Coder-480B-A35B-Instruct"
166 case "huggingface":170 case "huggingface":
@@ -773,7 +777,16 @@ func (r Resolved) NewClient() provider.Client {
773 case "groq":777 case "groq":
774 return wrap(provider.NewGroq(r.Credential, r.BaseURL))778 return wrap(provider.NewGroq(r.Credential, r.BaseURL))
775 case "xai":779 case "xai":
776- return wrap(provider.NewXAI(r.Credential, r.BaseURL))780+ var inner provider.Client
781+ if r.Model == "grok-4.5" {
782+ inner = wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "xai"))
783+ } else {
784+ inner = wrap(provider.NewXAI(r.Credential, r.BaseURL))
785+ }
786+ if r.AuthMethod == "oauth" {
787+ return r.wrapWithRefresh(inner)
788+ }
789+ return inner
777 case "together":790 case "together":
778 return wrap(provider.NewTogether(r.Credential, r.BaseURL))791 return wrap(provider.NewTogether(r.Credential, r.BaseURL))
779 case "huggingface":792 case "huggingface":
@@ -868,6 +881,11 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
868 case "kimi":881 case "kimi":
869 // anthropic-messages on api.kimi.com/coding.882 // anthropic-messages on api.kimi.com/coding.
870 return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))883 return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
884+ case "xai":
885+ if r.Model == "grok-4.5" {
886+ return r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, baseURL, "xai"))
887+ }
888+ return r.withHTTPClient(provider.NewXAI(token, baseURL))
871 default:889 default:
872 return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))890 return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
873 }891 }
M packages/agent/build_test.go +6 -0
@@ -406,6 +406,12 @@ func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
406406 assertDefaultTransportStillSecure(t)
407407 }
408408
409+func TestDefaultXAIModelIsGrok45(t *testing.T) {
410+ if got := defaultModelForProvider("xai"); got != "grok-4.5" {
411+ t.Fatalf("default xAI model = %q, want grok-4.5", got)
412+ }
413+}
414+
409415 func assertDefaultTransportStillSecure(t *testing.T) {
410416 t.Helper()
411417 tr, ok := http.DefaultTransport.(*http.Transport)
@@ -406,6 +406,12 @@ func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
406 assertDefaultTransportStillSecure(t)406 assertDefaultTransportStillSecure(t)
407 }407 }
408 408
409+func TestDefaultXAIModelIsGrok45(t *testing.T) {
410+ if got := defaultModelForProvider("xai"); got != "grok-4.5" {
411+ t.Fatalf("default xAI model = %q, want grok-4.5", got)
412+ }
413+}
414+
409 func assertDefaultTransportStillSecure(t *testing.T) {415 func assertDefaultTransportStillSecure(t *testing.T) {
410 t.Helper()416 t.Helper()
411 tr, ok := http.DefaultTransport.(*http.Transport)417 tr, ok := http.DefaultTransport.(*http.Transport)
M packages/agent/cli.go +2 -0
@@ -106,6 +106,7 @@ func (a *extToolAdapter) Tools() []ExtensionToolInfo {
106106 Name: t.Name,
107107 Description: t.Description,
108108 Schema: t.Schema,
109+ Deferred: t.Deferred,
109110 }
110111 }
111112 return out
@@ -117,6 +118,7 @@ func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
117118 Name: info.Name,
118119 Description: info.Description,
119120 Schema: info.Schema,
121+ Deferred: info.Deferred,
120122 })
121123 }
122124
@@ -106,6 +106,7 @@ func (a *extToolAdapter) Tools() []ExtensionToolInfo {
106 Name: t.Name,106 Name: t.Name,
107 Description: t.Description,107 Description: t.Description,
108 Schema: t.Schema,108 Schema: t.Schema,
109+ Deferred: t.Deferred,
109 }110 }
110 }111 }
111 return out112 return out
@@ -117,6 +118,7 @@ func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
117 Name: info.Name,118 Name: info.Name,
118 Description: info.Description,119 Description: info.Description,
119 Schema: info.Schema,120 Schema: info.Schema,
121+ Deferred: info.Deferred,
120 })122 })
121 }123 }
122 124
M packages/agent/config.go +27 -2
@@ -407,8 +407,14 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
407407 if err != nil {
408408 return "", "", "", err
409409 }
410- if pc, ok := c.AdditionalAPIKeyCreds[provider]; ok && pc.APIKey != "" {
411- return pc.APIKey, "apikey", "", nil
410+ if pc, ok := c.AdditionalAPIKeyCreds[provider]; ok {
411+ if pc.APIKey != "" {
412+ return pc.APIKey, "apikey", "", nil
413+ }
414+ if provider == "xai" && pc.OAuth != nil && pc.OAuth.AccessToken != "" {
415+ tok, _ := refreshIfExpired(provider, pc.OAuth)
416+ return tok.AccessToken, "oauth", "", nil
417+ }
412418 }
413419 switch provider {
414420 case "anthropic":
@@ -554,6 +560,9 @@ func loadOAuthToken(providerName string) *auth.OAuthToken {
554560 return c.GithubCopilot.OAuth
555561 }
556562 }
563+ if pc, ok := c.AdditionalAPIKeyCreds[providerName]; ok {
564+ return pc.OAuth
565+ }
557566 return nil
558567 }
559568
@@ -575,6 +584,22 @@ func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthTok
575584 return tok, fmt.Errorf("%s oauth token expired and no refresh_token available — run /login again", providerName)
576585 }
577586
587+ if providerName == "xai" {
588+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
589+ defer cancel()
590+ next, err := auth.RefreshXAIToken(ctx, tok.RefreshToken)
591+ if err != nil {
592+ return tok, fmt.Errorf("refresh %s: %w", providerName, err)
593+ }
594+ if next.RefreshToken == "" {
595+ next.RefreshToken = tok.RefreshToken
596+ }
597+ if err := AuthStoreFor().SetOAuth(providerName, *next); err != nil {
598+ return next, fmt.Errorf("persist refreshed token: %w", err)
599+ }
600+ return next, nil
601+ }
602+
578603 var op auth.OAuthProvider
579604 switch providerName {
580605 case "anthropic":
@@ -407,8 +407,14 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
407 if err != nil {407 if err != nil {
408 return "", "", "", err408 return "", "", "", err
409 }409 }
410- if pc, ok := c.AdditionalAPIKeyCreds[provider]; ok && pc.APIKey != "" {410+ if pc, ok := c.AdditionalAPIKeyCreds[provider]; ok {
411- return pc.APIKey, "apikey", "", nil411+ if pc.APIKey != "" {
412+ return pc.APIKey, "apikey", "", nil
413+ }
414+ if provider == "xai" && pc.OAuth != nil && pc.OAuth.AccessToken != "" {
415+ tok, _ := refreshIfExpired(provider, pc.OAuth)
416+ return tok.AccessToken, "oauth", "", nil
417+ }
412 }418 }
413 switch provider {419 switch provider {
414 case "anthropic":420 case "anthropic":
@@ -554,6 +560,9 @@ func loadOAuthToken(providerName string) *auth.OAuthToken {
554 return c.GithubCopilot.OAuth560 return c.GithubCopilot.OAuth
555 }561 }
556 }562 }
563+ if pc, ok := c.AdditionalAPIKeyCreds[providerName]; ok {
564+ return pc.OAuth
565+ }
557 return nil566 return nil
558 }567 }
559 568
@@ -575,6 +584,22 @@ func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthTok
575 return tok, fmt.Errorf("%s oauth token expired and no refresh_token available — run /login again", providerName)584 return tok, fmt.Errorf("%s oauth token expired and no refresh_token available — run /login again", providerName)
576 }585 }
577 586
587+ if providerName == "xai" {
588+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
589+ defer cancel()
590+ next, err := auth.RefreshXAIToken(ctx, tok.RefreshToken)
591+ if err != nil {
592+ return tok, fmt.Errorf("refresh %s: %w", providerName, err)
593+ }
594+ if next.RefreshToken == "" {
595+ next.RefreshToken = tok.RefreshToken
596+ }
597+ if err := AuthStoreFor().SetOAuth(providerName, *next); err != nil {
598+ return next, fmt.Errorf("persist refreshed token: %w", err)
599+ }
600+ return next, nil
601+ }
602+
578 var op auth.OAuthProvider603 var op auth.OAuthProvider
579 switch providerName {604 switch providerName {
580 case "anthropic":605 case "anthropic":
M packages/agent/ext/ext.go +21 -7
@@ -151,8 +151,9 @@ type AssistantMessageHandler func(text string) AssistantMessageDecision
151151 // one with TextResult, ImageResult, or directly when you need to
152152 // combine multiple blocks.
153153 type ToolResult struct {
154- Content []ToolContent
155- IsError bool
154+ Content []ToolContent
155+ IsError bool
156+ ActivateTools []string
156157 }
157158
158159 // ToolContent is one block of tool output. Either Text is set, or
@@ -279,6 +280,7 @@ type toolDef struct {
279280 name string
280281 description string
281282 schema json.RawMessage
283+ deferred bool
282284 }
283285
284286 // HostInfo is what the host (zot) tells us in HelloAck. Useful for
@@ -388,9 +390,19 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
388390 // Naming conflicts with built-in tools (read, write, edit, bash,
389391 // skill) are silently shadowed by the built-in.
390392 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
393+ e.registerTool(name, description, schema, false, fn)
394+}
395+
396+// DeferredTool registers a tool whose definition stays hidden until another
397+// tool result names it in ActivateTools.
398+func (e *Extension) DeferredTool(name, description string, schema json.RawMessage, fn ToolHandler) {
399+ e.registerTool(name, description, schema, true, fn)
400+}
401+
402+func (e *Extension) registerTool(name, description string, schema json.RawMessage, deferred bool, fn ToolHandler) {
391403 e.mu.Lock()
392404 e.tools[name] = fn
393- e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema})
405+ e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema, deferred: deferred})
394406 e.mu.Unlock()
395407 }
396408
@@ -532,6 +544,7 @@ func (e *Extension) Run() error {
532544 Name: td.name,
533545 Description: td.description,
534546 Schema: td.schema,
547+ Deferred: td.deferred,
535548 })
536549 }
537550 var intercepts []string
@@ -704,10 +717,11 @@ func (e *Extension) respondTool(id string, r ToolResult) {
704717 })
705718 }
706719 _ = e.send(extproto.ToolResultFromExt{
707- Type: "tool_result",
708- ID: id,
709- Content: blocks,
710- IsError: r.IsError,
720+ Type: "tool_result",
721+ ID: id,
722+ Content: blocks,
723+ IsError: r.IsError,
724+ ActivateTools: r.ActivateTools,
711725 })
712726 }
713727
@@ -151,8 +151,9 @@ type AssistantMessageHandler func(text string) AssistantMessageDecision
151 // one with TextResult, ImageResult, or directly when you need to151 // one with TextResult, ImageResult, or directly when you need to
152 // combine multiple blocks.152 // combine multiple blocks.
153 type ToolResult struct {153 type ToolResult struct {
154- Content []ToolContent154+ Content []ToolContent
155- IsError bool155+ IsError bool
156+ ActivateTools []string
156 }157 }
157 158
158 // ToolContent is one block of tool output. Either Text is set, or159 // ToolContent is one block of tool output. Either Text is set, or
@@ -279,6 +280,7 @@ type toolDef struct {
279 name string280 name string
280 description string281 description string
281 schema json.RawMessage282 schema json.RawMessage
283+ deferred bool
282 }284 }
283 285
284 // HostInfo is what the host (zot) tells us in HelloAck. Useful for286 // HostInfo is what the host (zot) tells us in HelloAck. Useful for
@@ -388,9 +390,19 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
388 // Naming conflicts with built-in tools (read, write, edit, bash,390 // Naming conflicts with built-in tools (read, write, edit, bash,
389 // skill) are silently shadowed by the built-in.391 // skill) are silently shadowed by the built-in.
390 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {392 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
393+ e.registerTool(name, description, schema, false, fn)
394+}
395+
396+// DeferredTool registers a tool whose definition stays hidden until another
397+// tool result names it in ActivateTools.
398+func (e *Extension) DeferredTool(name, description string, schema json.RawMessage, fn ToolHandler) {
399+ e.registerTool(name, description, schema, true, fn)
400+}
401+
402+func (e *Extension) registerTool(name, description string, schema json.RawMessage, deferred bool, fn ToolHandler) {
391 e.mu.Lock()403 e.mu.Lock()
392 e.tools[name] = fn404 e.tools[name] = fn
393- e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema})405+ e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema, deferred: deferred})
394 e.mu.Unlock()406 e.mu.Unlock()
395 }407 }
396 408
@@ -532,6 +544,7 @@ func (e *Extension) Run() error {
532 Name: td.name,544 Name: td.name,
533 Description: td.description,545 Description: td.description,
534 Schema: td.schema,546 Schema: td.schema,
547+ Deferred: td.deferred,
535 })548 })
536 }549 }
537 var intercepts []string550 var intercepts []string
@@ -704,10 +717,11 @@ func (e *Extension) respondTool(id string, r ToolResult) {
704 })717 })
705 }718 }
706 _ = e.send(extproto.ToolResultFromExt{719 _ = e.send(extproto.ToolResultFromExt{
707- Type: "tool_result",720+ Type: "tool_result",
708- ID: id,721+ ID: id,
709- Content: blocks,722+ Content: blocks,
710- IsError: r.IsError,723+ IsError: r.IsError,
724+ ActivateTools: r.ActivateTools,
711 })725 })
712 }726 }
713 727
M packages/agent/ext/ext_test.go +49 -0
@@ -220,6 +220,55 @@ func TestOpenPanelEmitsCorrectFrame(t *testing.T) {
220220 h.hostW.Close()
221221 }
222222
223+func TestDeferredToolRegistrationAndActivation(t *testing.T) {
224+ h := newHarness("deferred-ext")
225+ h.ext.DeferredTool("weather", "weather lookup", json.RawMessage(`{"type":"object"}`), func(json.RawMessage) ToolResult {
226+ return TextResult("sunny")
227+ })
228+ h.ext.Tool("search_tools", "find tools", json.RawMessage(`{"type":"object"}`), func(json.RawMessage) ToolResult {
229+ result := TextResult("enabled")
230+ result.ActivateTools = []string{"weather"}
231+ return result
232+ })
233+
234+ go h.ext.Run()
235+ if frame := h.next(t); frame.hdr.Type != "hello" {
236+ t.Fatalf("expected hello, got %q", frame.hdr.Type)
237+ }
238+ h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack", ProtocolVersion: extproto.ProtocolVersion})
239+
240+ deferred := false
241+ for {
242+ frame := h.next(t)
243+ if frame.hdr.Type == "ready" {
244+ break
245+ }
246+ if frame.hdr.Type == "register_tool" {
247+ var registration extproto.RegisterToolFromExt
248+ if err := json.Unmarshal(frame.raw, &registration); err != nil {
249+ t.Fatal(err)
250+ }
251+ if registration.Name == "weather" {
252+ deferred = registration.Deferred
253+ }
254+ }
255+ }
256+ if !deferred {
257+ t.Fatal("weather registration was not deferred")
258+ }
259+
260+ h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: "call-1", Name: "search_tools", Args: json.RawMessage(`{}`)})
261+ frame := h.drainUntil(t, "tool_result")
262+ var result extproto.ToolResultFromExt
263+ if err := json.Unmarshal(frame.raw, &result); err != nil {
264+ t.Fatal(err)
265+ }
266+ if len(result.ActivateTools) != 1 || result.ActivateTools[0] != "weather" {
267+ t.Fatalf("activate_tools = %v", result.ActivateTools)
268+ }
269+ h.hostW.Close()
270+}
271+
223272 // TestBlockingToolWaitsForPanelKey is the core integration test for
224273 // the human-in-the-loop pattern: the tool handler opens a panel,
225274 // blocks on a channel, and only returns a tool_result after a key
@@ -220,6 +220,55 @@ func TestOpenPanelEmitsCorrectFrame(t *testing.T) {
220 h.hostW.Close()220 h.hostW.Close()
221 }221 }
222 222
223+func TestDeferredToolRegistrationAndActivation(t *testing.T) {
224+ h := newHarness("deferred-ext")
225+ h.ext.DeferredTool("weather", "weather lookup", json.RawMessage(`{"type":"object"}`), func(json.RawMessage) ToolResult {
226+ return TextResult("sunny")
227+ })
228+ h.ext.Tool("search_tools", "find tools", json.RawMessage(`{"type":"object"}`), func(json.RawMessage) ToolResult {
229+ result := TextResult("enabled")
230+ result.ActivateTools = []string{"weather"}
231+ return result
232+ })
233+
234+ go h.ext.Run()
235+ if frame := h.next(t); frame.hdr.Type != "hello" {
236+ t.Fatalf("expected hello, got %q", frame.hdr.Type)
237+ }
238+ h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack", ProtocolVersion: extproto.ProtocolVersion})
239+
240+ deferred := false
241+ for {
242+ frame := h.next(t)
243+ if frame.hdr.Type == "ready" {
244+ break
245+ }
246+ if frame.hdr.Type == "register_tool" {
247+ var registration extproto.RegisterToolFromExt
248+ if err := json.Unmarshal(frame.raw, &registration); err != nil {
249+ t.Fatal(err)
250+ }
251+ if registration.Name == "weather" {
252+ deferred = registration.Deferred
253+ }
254+ }
255+ }
256+ if !deferred {
257+ t.Fatal("weather registration was not deferred")
258+ }
259+
260+ h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: "call-1", Name: "search_tools", Args: json.RawMessage(`{}`)})
261+ frame := h.drainUntil(t, "tool_result")
262+ var result extproto.ToolResultFromExt
263+ if err := json.Unmarshal(frame.raw, &result); err != nil {
264+ t.Fatal(err)
265+ }
266+ if len(result.ActivateTools) != 1 || result.ActivateTools[0] != "weather" {
267+ t.Fatalf("activate_tools = %v", result.ActivateTools)
268+ }
269+ h.hostW.Close()
270+}
271+
223 // TestBlockingToolWaitsForPanelKey is the core integration test for272 // TestBlockingToolWaitsForPanelKey is the core integration test for
224 // the human-in-the-loop pattern: the tool handler opens a panel,273 // the human-in-the-loop pattern: the tool handler opens a panel,
225 // blocks on a channel, and only returns a tool_result after a key274 // blocks on a channel, and only returns a tool_result after a key
M packages/agent/extensions/manager.go +2 -0
@@ -966,6 +966,7 @@ type ToolInfo struct {
966966 Name string
967967 Description string
968968 Schema json.RawMessage
969+ Deferred bool
969970 }
970971
971972 // Tools returns a snapshot of every (extension, tool) pair currently
@@ -982,6 +983,7 @@ func (m *Manager) Tools() []ToolInfo {
982983 Name: t.Name,
983984 Description: t.Description,
984985 Schema: t.Schema,
986+ Deferred: t.Deferred,
985987 })
986988 }
987989 }
@@ -966,6 +966,7 @@ type ToolInfo struct {
966 Name string966 Name string
967 Description string967 Description string
968 Schema json.RawMessage968 Schema json.RawMessage
969+ Deferred bool
969 }970 }
970 971
971 // Tools returns a snapshot of every (extension, tool) pair currently972 // Tools returns a snapshot of every (extension, tool) pair currently
@@ -982,6 +983,7 @@ func (m *Manager) Tools() []ToolInfo {
982 Name: t.Name,983 Name: t.Name,
983 Description: t.Description,984 Description: t.Description,
984 Schema: t.Schema,985 Schema: t.Schema,
986+ Deferred: t.Deferred,
985 })987 })
986 }988 }
987 }989 }
M packages/agent/extensions/tool.go +4 -1
@@ -25,6 +25,7 @@ type extensionTool struct {
2525 extension string
2626 manager *Manager
2727 timeout time.Duration
28+ deferred bool
2829 }
2930
3031 // NewTool returns a core.Tool that round-trips invocations through
@@ -38,6 +39,7 @@ func NewTool(mgr *Manager, info ToolInfo) core.Tool {
3839 extension: info.Extension,
3940 manager: mgr,
4041 timeout: 60 * time.Second,
42+ deferred: info.Deferred,
4143 }
4244 }
4345
@@ -45,6 +47,7 @@ func (t *extensionTool) Name() string { return t.name }
4547 func (t *extensionTool) Description() string { return t.description }
4648 func (t *extensionTool) Schema() json.RawMessage { return t.schema }
4749 func (t *extensionTool) Extension() string { return t.extension }
50+func (t *extensionTool) Deferred() bool { return t.deferred }
4851
4952 // Execute is what the agent calls when the LLM invokes the tool. It
5053 // hands args to the owning extension, waits up to t.timeout for the
@@ -60,7 +63,7 @@ func (t *extensionTool) Execute(ctx context.Context, args json.RawMessage, _ fun
6063 Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("extension %s/%s failed: %v", t.extension, t.name, err)}},
6164 }, nil
6265 }
63- out := core.ToolResult{IsError: resp.IsError}
66+ out := core.ToolResult{IsError: resp.IsError, ActivateTools: resp.ActivateTools}
6467 for _, b := range resp.Content {
6568 switch b.Type {
6669 case "text":
@@ -25,6 +25,7 @@ type extensionTool struct {
25 extension string25 extension string
26 manager *Manager26 manager *Manager
27 timeout time.Duration27 timeout time.Duration
28+ deferred bool
28 }29 }
29 30
30 // NewTool returns a core.Tool that round-trips invocations through31 // NewTool returns a core.Tool that round-trips invocations through
@@ -38,6 +39,7 @@ func NewTool(mgr *Manager, info ToolInfo) core.Tool {
38 extension: info.Extension,39 extension: info.Extension,
39 manager: mgr,40 manager: mgr,
40 timeout: 60 * time.Second,41 timeout: 60 * time.Second,
42+ deferred: info.Deferred,
41 }43 }
42 }44 }
43 45
@@ -45,6 +47,7 @@ func (t *extensionTool) Name() string { return t.name }
45 func (t *extensionTool) Description() string { return t.description }47 func (t *extensionTool) Description() string { return t.description }
46 func (t *extensionTool) Schema() json.RawMessage { return t.schema }48 func (t *extensionTool) Schema() json.RawMessage { return t.schema }
47 func (t *extensionTool) Extension() string { return t.extension }49 func (t *extensionTool) Extension() string { return t.extension }
50+func (t *extensionTool) Deferred() bool { return t.deferred }
48 51
49 // Execute is what the agent calls when the LLM invokes the tool. It52 // Execute is what the agent calls when the LLM invokes the tool. It
50 // hands args to the owning extension, waits up to t.timeout for the53 // hands args to the owning extension, waits up to t.timeout for the
@@ -60,7 +63,7 @@ func (t *extensionTool) Execute(ctx context.Context, args json.RawMessage, _ fun
60 Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("extension %s/%s failed: %v", t.extension, t.name, err)}},63 Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("extension %s/%s failed: %v", t.extension, t.name, err)}},
61 }, nil64 }, nil
62 }65 }
63- out := core.ToolResult{IsError: resp.IsError}66+ out := core.ToolResult{IsError: resp.IsError, ActivateTools: resp.ActivateTools}
64 for _, b := range resp.Content {67 for _, b := range resp.Content {
65 switch b.Type {68 switch b.Type {
66 case "text":69 case "text":
M packages/agent/extproto/extproto.go +6 -4
@@ -44,6 +44,7 @@ type RegisterToolFromExt struct {
4444 Name string `json:"name"`
4545 Description string `json:"description,omitempty"`
4646 Schema json.RawMessage `json:"schema"`
47+ Deferred bool `json:"deferred,omitempty"`
4748 }
4849
4950 type ReadyFromExt struct {
@@ -66,10 +67,11 @@ type EventInterceptResponseFromExt struct {
6667 }
6768
6869 type ToolResultFromExt struct {
69- Type string `json:"type"`
70- ID string `json:"id"`
71- Content []ContentBlock `json:"content"`
72- IsError bool `json:"is_error,omitempty"`
70+ Type string `json:"type"`
71+ ID string `json:"id"`
72+ Content []ContentBlock `json:"content"`
73+ IsError bool `json:"is_error,omitempty"`
74+ ActivateTools []string `json:"activate_tools,omitempty"`
7375 }
7476
7577 type ContentBlock struct {
@@ -44,6 +44,7 @@ type RegisterToolFromExt struct {
44 Name string `json:"name"`44 Name string `json:"name"`
45 Description string `json:"description,omitempty"`45 Description string `json:"description,omitempty"`
46 Schema json.RawMessage `json:"schema"`46 Schema json.RawMessage `json:"schema"`
47+ Deferred bool `json:"deferred,omitempty"`
47 }48 }
48 49
49 type ReadyFromExt struct {50 type ReadyFromExt struct {
@@ -66,10 +67,11 @@ type EventInterceptResponseFromExt struct {
66 }67 }
67 68
68 type ToolResultFromExt struct {69 type ToolResultFromExt struct {
69- Type string `json:"type"`70+ Type string `json:"type"`
70- ID string `json:"id"`71+ ID string `json:"id"`
71- Content []ContentBlock `json:"content"`72+ Content []ContentBlock `json:"content"`
72- IsError bool `json:"is_error,omitempty"`73+ IsError bool `json:"is_error,omitempty"`
74+ ActivateTools []string `json:"activate_tools,omitempty"`
73 }75 }
74 76
75 type ContentBlock struct {77 type ContentBlock struct {
M packages/agent/modes/interactive.go +11 -0
@@ -4275,6 +4275,17 @@ func (i *Interactive) startOAuthFlow(provider string) {
42754275 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
42764276 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
42774277 }
4278+ // Device-code providers already support headless login and must only
4279+ // start one polling flow.
4280+ if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
4281+ loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
4282+ if err != nil {
4283+ i.dialog.ShowResult(false, err.Error())
4284+ return
4285+ }
4286+ i.dialog.ShowWaiting(loginURL)
4287+ return
4288+ }
42784289 // Always run the manual/copy-code flow in parallel with the local
42794290 // callback server so headless environments (docker, SSH) can paste
42804291 // the authorization code directly without first pressing 'p'.
@@ -4275,6 +4275,17 @@ func (i *Interactive) startOAuthFlow(provider string) {
4275 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {4275 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
4276 _ = i.cfg.SetKimiCLIFallbackDisabled(false)4276 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
4277 }4277 }
4278+ // Device-code providers already support headless login and must only
4279+ // start one polling flow.
4280+ if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
4281+ loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
4282+ if err != nil {
4283+ i.dialog.ShowResult(false, err.Error())
4284+ return
4285+ }
4286+ i.dialog.ShowWaiting(loginURL)
4287+ return
4288+ }
4278 // Always run the manual/copy-code flow in parallel with the local4289 // Always run the manual/copy-code flow in parallel with the local
4279 // callback server so headless environments (docker, SSH) can paste4290 // callback server so headless environments (docker, SSH) can paste
4280 // the authorization code directly without first pressing 'p'.4291 // the authorization code directly without first pressing 'p'.
M packages/agent/modes/login_dialog.go +11 -2
@@ -149,7 +149,11 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
149149 for i := start; i < end; i++ {
150150 o := opts[i]
151151 tag := providerPickerTag(d.method, d.status[o])
152- label := " " + providerLabel(o)
152+ providerName := providerLabel(o)
153+ if d.method == "oauth" && o == "xai" {
154+ providerName = "Sign in with SuperGrok or X Premium"
155+ }
156+ label := " " + providerName
153157 plain := label + tag
154158 if i == d.cursor {
155159 lines = append(lines, th.PadHighlight(plain, width))
@@ -172,6 +176,11 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
172176 lines = append(lines, th.FG256(th.Accent, seg))
173177 }
174178 lines = append(lines, "")
179+ if d.provider == "kimi" || d.provider == "xai" || d.provider == "github-copilot" {
180+ lines = append(lines, th.FG256(th.Muted, "complete sign-in in the browser - esc cancels"))
181+ lines = append(lines, frameRule(th, width))
182+ break
183+ }
175184 lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))
176185 if d.codeEd == nil {
177186 d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))
@@ -241,7 +250,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
241250 func providersForMethod(method string) []string {
242251 var providers []string
243252 if method == "oauth" {
244- providers = []string{"anthropic", "openai-codex", "kimi", "github-copilot"}
253+ providers = []string{"anthropic", "openai-codex", "kimi", "xai", "github-copilot"}
245254 } else {
246255 providers = auth.APIKeyProviders()
247256 // Append custom providers from models.json so they appear
@@ -149,7 +149,11 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
149 for i := start; i < end; i++ {149 for i := start; i < end; i++ {
150 o := opts[i]150 o := opts[i]
151 tag := providerPickerTag(d.method, d.status[o])151 tag := providerPickerTag(d.method, d.status[o])
152- label := " " + providerLabel(o)152+ providerName := providerLabel(o)
153+ if d.method == "oauth" && o == "xai" {
154+ providerName = "Sign in with SuperGrok or X Premium"
155+ }
156+ label := " " + providerName
153 plain := label + tag157 plain := label + tag
154 if i == d.cursor {158 if i == d.cursor {
155 lines = append(lines, th.PadHighlight(plain, width))159 lines = append(lines, th.PadHighlight(plain, width))
@@ -172,6 +176,11 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
172 lines = append(lines, th.FG256(th.Accent, seg))176 lines = append(lines, th.FG256(th.Accent, seg))
173 }177 }
174 lines = append(lines, "")178 lines = append(lines, "")
179+ if d.provider == "kimi" || d.provider == "xai" || d.provider == "github-copilot" {
180+ lines = append(lines, th.FG256(th.Muted, "complete sign-in in the browser - esc cancels"))
181+ lines = append(lines, frameRule(th, width))
182+ break
183+ }
175 lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))184 lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))
176 if d.codeEd == nil {185 if d.codeEd == nil {
177 d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))186 d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))
@@ -241,7 +250,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
241 func providersForMethod(method string) []string {250 func providersForMethod(method string) []string {
242 var providers []string251 var providers []string
243 if method == "oauth" {252 if method == "oauth" {
244- providers = []string{"anthropic", "openai-codex", "kimi", "github-copilot"}253+ providers = []string{"anthropic", "openai-codex", "kimi", "xai", "github-copilot"}
245 } else {254 } else {
246 providers = auth.APIKeyProviders()255 providers = auth.APIKeyProviders()
247 // Append custom providers from models.json so they appear256 // Append custom providers from models.json so they appear
M packages/core/agent.go +19 -3
@@ -629,6 +629,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St
629629 // a single tool-role message carrying all results.
630630 func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) {
631631 var results []provider.Content
632+ var addedTools []string
632633 hadError := false
633634
634635 for _, c := range msg.Content {
@@ -645,13 +646,19 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
645646 Content: res.Content,
646647 IsError: res.IsError,
647648 })
649+ for _, name := range res.ActivateTools {
650+ if _, err := a.Tools.Get(name); err == nil && !containsString(addedTools, name) {
651+ addedTools = append(addedTools, name)
652+ }
653+ }
648654 sink(EvToolResult{ID: tc.ID, Result: res})
649655 }
650656
651657 return provider.Message{
652- Role: provider.RoleTool,
653- Content: results,
654- Time: time.Now(),
658+ Role: provider.RoleTool,
659+ Content: results,
660+ Time: time.Now(),
661+ AddedToolNames: addedTools,
655662 }, hadError
656663 }
657664
@@ -757,6 +764,15 @@ func mirrorToolImagesAsUser(msg provider.Message) provider.Message {
757764 return provider.Message{Role: provider.RoleUser, Content: content, Time: time.Now()}
758765 }
759766
767+func containsString(values []string, want string) bool {
768+ for _, value := range values {
769+ if value == want {
770+ return true
771+ }
772+ }
773+ return false
774+}
775+
760776 func extractText(msg provider.Message) string {
761777 var out string
762778 for _, c := range msg.Content {
@@ -629,6 +629,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St
629 // a single tool-role message carrying all results.629 // a single tool-role message carrying all results.
630 func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) {630 func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) {
631 var results []provider.Content631 var results []provider.Content
632+ var addedTools []string
632 hadError := false633 hadError := false
633 634
634 for _, c := range msg.Content {635 for _, c := range msg.Content {
@@ -645,13 +646,19 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
645 Content: res.Content,646 Content: res.Content,
646 IsError: res.IsError,647 IsError: res.IsError,
647 })648 })
649+ for _, name := range res.ActivateTools {
650+ if _, err := a.Tools.Get(name); err == nil && !containsString(addedTools, name) {
651+ addedTools = append(addedTools, name)
652+ }
653+ }
648 sink(EvToolResult{ID: tc.ID, Result: res})654 sink(EvToolResult{ID: tc.ID, Result: res})
649 }655 }
650 656
651 return provider.Message{657 return provider.Message{
652- Role: provider.RoleTool,658+ Role: provider.RoleTool,
653- Content: results,659+ Content: results,
654- Time: time.Now(),660+ Time: time.Now(),
661+ AddedToolNames: addedTools,
655 }, hadError662 }, hadError
656 }663 }
657 664
@@ -757,6 +764,15 @@ func mirrorToolImagesAsUser(msg provider.Message) provider.Message {
757 return provider.Message{Role: provider.RoleUser, Content: content, Time: time.Now()}764 return provider.Message{Role: provider.RoleUser, Content: content, Time: time.Now()}
758 }765 }
759 766
767+func containsString(values []string, want string) bool {
768+ for _, value := range values {
769+ if value == want {
770+ return true
771+ }
772+ }
773+ return false
774+}
775+
760 func extractText(msg provider.Message) string {776 func extractText(msg provider.Message) string {
761 var out string777 var out string
762 for _, c := range msg.Content {778 for _, c := range msg.Content {
M packages/core/compact.go +10 -1
@@ -89,8 +89,17 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
8989
9090 // Replace transcript: one synthetic user message with the summary,
9191 // followed by the preserved tail (if any).
92+ var activatedTools []string
93+ for _, message := range msgs {
94+ for _, name := range message.AddedToolNames {
95+ if !containsString(activatedTools, name) {
96+ activatedTools = append(activatedTools, name)
97+ }
98+ }
99+ }
92100 synthetic := provider.Message{
93- Role: provider.RoleUser,
101+ Role: provider.RoleUser,
102+ AddedToolNames: activatedTools,
94103 Content: []provider.Content{
95104 provider.TextBlock{Text: "## Context Summary (compacted)\n\n" + summary},
96105 },
@@ -89,8 +89,17 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
89 89
90 // Replace transcript: one synthetic user message with the summary,90 // Replace transcript: one synthetic user message with the summary,
91 // followed by the preserved tail (if any).91 // followed by the preserved tail (if any).
92+ var activatedTools []string
93+ for _, message := range msgs {
94+ for _, name := range message.AddedToolNames {
95+ if !containsString(activatedTools, name) {
96+ activatedTools = append(activatedTools, name)
97+ }
98+ }
99+ }
92 synthetic := provider.Message{100 synthetic := provider.Message{
93- Role: provider.RoleUser,101+ Role: provider.RoleUser,
102+ AddedToolNames: activatedTools,
94 Content: []provider.Content{103 Content: []provider.Content{
95 provider.TextBlock{Text: "## Context Summary (compacted)\n\n" + summary},104 provider.TextBlock{Text: "## Context Summary (compacted)\n\n" + summary},
96 },105 },
M packages/core/session.go +6 -5
@@ -809,15 +809,16 @@ func hydrateMessage(lineBytes []byte) (provider.Message, error) {
809809
810810 func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
811811 var row struct {
812- Role provider.Role `json:"role"`
813- Content []json.RawMessage `json:"content"`
814- Time time.Time `json:"time"`
815- Meta map[string]string `json:"meta,omitempty"`
812+ Role provider.Role `json:"role"`
813+ Content []json.RawMessage `json:"content"`
814+ Time time.Time `json:"time"`
815+ Meta map[string]string `json:"meta,omitempty"`
816+ AddedToolNames []string `json:"added_tool_names,omitempty"`
816817 }
817818 if err := json.Unmarshal(rawMessage, &row); err != nil {
818819 return provider.Message{}, err
819820 }
820- msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta}
821+ msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta, AddedToolNames: row.AddedToolNames}
821822 for _, raw := range row.Content {
822823 var head struct {
823824 Text string `json:"text"`
@@ -809,15 +809,16 @@ func hydrateMessage(lineBytes []byte) (provider.Message, error) {
809 809
810 func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {810 func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
811 var row struct {811 var row struct {
812- Role provider.Role `json:"role"`812+ Role provider.Role `json:"role"`
813- Content []json.RawMessage `json:"content"`813+ Content []json.RawMessage `json:"content"`
814- Time time.Time `json:"time"`814+ Time time.Time `json:"time"`
815- Meta map[string]string `json:"meta,omitempty"`815+ Meta map[string]string `json:"meta,omitempty"`
816+ AddedToolNames []string `json:"added_tool_names,omitempty"`
816 }817 }
817 if err := json.Unmarshal(rawMessage, &row); err != nil {818 if err := json.Unmarshal(rawMessage, &row); err != nil {
818 return provider.Message{}, err819 return provider.Message{}, err
819 }820 }
820- msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta}821+ msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta, AddedToolNames: row.AddedToolNames}
821 for _, raw := range row.Content {822 for _, raw := range row.Content {
822 var head struct {823 var head struct {
823 Text string `json:"text"`824 Text string `json:"text"`
A packages/core/session_deferred_tools_test.go +38 -0
@@ -0,0 +1,38 @@
1+package core
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/provider"
7+)
8+
9+func TestSessionPreservesActivatedDeferredTools(t *testing.T) {
10+ session, err := NewSession(t.TempDir(), "/workspace", "moonshotai", "kimi-k3", "test")
11+ if err != nil {
12+ t.Fatal(err)
13+ }
14+ message := provider.Message{
15+ Role: provider.RoleTool,
16+ AddedToolNames: []string{"lookup_weather"},
17+ Content: []provider.Content{provider.ToolResultBlock{
18+ CallID: "call-1",
19+ Content: []provider.Content{provider.TextBlock{Text: "enabled"}},
20+ }},
21+ }
22+ if err := session.AppendMessage(message); err != nil {
23+ t.Fatal(err)
24+ }
25+ path := session.Path
26+ if err := session.Close(); err != nil {
27+ t.Fatal(err)
28+ }
29+
30+ reopened, messages, err := OpenSession(path)
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ defer reopened.Close()
35+ if len(messages) != 1 || len(messages[0].AddedToolNames) != 1 || messages[0].AddedToolNames[0] != "lookup_weather" {
36+ t.Fatalf("messages = %+v", messages)
37+ }
38+}
@@ -0,0 +1,38 @@
1+package core
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/provider"
7+)
8+
9+func TestSessionPreservesActivatedDeferredTools(t *testing.T) {
10+ session, err := NewSession(t.TempDir(), "/workspace", "moonshotai", "kimi-k3", "test")
11+ if err != nil {
12+ t.Fatal(err)
13+ }
14+ message := provider.Message{
15+ Role: provider.RoleTool,
16+ AddedToolNames: []string{"lookup_weather"},
17+ Content: []provider.Content{provider.ToolResultBlock{
18+ CallID: "call-1",
19+ Content: []provider.Content{provider.TextBlock{Text: "enabled"}},
20+ }},
21+ }
22+ if err := session.AppendMessage(message); err != nil {
23+ t.Fatal(err)
24+ }
25+ path := session.Path
26+ if err := session.Close(); err != nil {
27+ t.Fatal(err)
28+ }
29+
30+ reopened, messages, err := OpenSession(path)
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ defer reopened.Close()
35+ if len(messages) != 1 || len(messages[0].AddedToolNames) != 1 || messages[0].AddedToolNames[0] != "lookup_weather" {
36+ t.Fatalf("messages = %+v", messages)
37+ }
38+}
M packages/core/tool.go +8 -0
@@ -31,6 +31,9 @@ type ToolResult struct {
3131 Content []provider.Content
3232 // IsError marks this result as an error to the LLM.
3333 IsError bool
34+ // ActivateTools names previously deferred tools that become available
35+ // after this result. Unknown names are ignored by the agent.
36+ ActivateTools []string
3437 // Details is arbitrary data for UIs and logs; not sent to the LLM.
3538 Details any
3639 }
@@ -62,10 +65,15 @@ func (r Registry) Specs() []provider.Tool {
6265 out := make([]provider.Tool, 0, len(r))
6366 for _, name := range names {
6467 t := r[name]
68+ deferred := false
69+ if d, ok := t.(interface{ Deferred() bool }); ok {
70+ deferred = d.Deferred()
71+ }
6572 out = append(out, provider.Tool{
6673 Name: t.Name(),
6774 Description: t.Description(),
6875 Schema: t.Schema(),
76+ Deferred: deferred,
6977 })
7078 }
7179 return out
@@ -31,6 +31,9 @@ type ToolResult struct {
31 Content []provider.Content31 Content []provider.Content
32 // IsError marks this result as an error to the LLM.32 // IsError marks this result as an error to the LLM.
33 IsError bool33 IsError bool
34+ // ActivateTools names previously deferred tools that become available
35+ // after this result. Unknown names are ignored by the agent.
36+ ActivateTools []string
34 // Details is arbitrary data for UIs and logs; not sent to the LLM.37 // Details is arbitrary data for UIs and logs; not sent to the LLM.
35 Details any38 Details any
36 }39 }
@@ -62,10 +65,15 @@ func (r Registry) Specs() []provider.Tool {
62 out := make([]provider.Tool, 0, len(r))65 out := make([]provider.Tool, 0, len(r))
63 for _, name := range names {66 for _, name := range names {
64 t := r[name]67 t := r[name]
68+ deferred := false
69+ if d, ok := t.(interface{ Deferred() bool }); ok {
70+ deferred = d.Deferred()
71+ }
65 out = append(out, provider.Tool{72 out = append(out, provider.Tool{
66 Name: t.Name(),73 Name: t.Name(),
67 Description: t.Description(),74 Description: t.Description(),
68 Schema: t.Schema(),75 Schema: t.Schema(),
76+ Deferred: deferred,
69 })77 })
70 }78 }
71 return out79 return out
M packages/provider/auth/manager.go +43 -2
@@ -136,6 +136,9 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
136136 if provider == "kimi" {
137137 return m.StartKimiDeviceOAuth()
138138 }
139+ if provider == "xai" {
140+ return m.StartXAIDeviceOAuth()
141+ }
139142 if provider == "github-copilot" {
140143 return m.StartGitHubCopilotDeviceOAuth()
141144 }
@@ -152,7 +155,7 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
152155 case "deepseek":
153156 return "", fmt.Errorf("deepseek login is api-key only; use api key login")
154157 default:
155- return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, github-copilot, deepseek, or google")
158+ return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, xai, github-copilot, deepseek, or google")
156159 }
157160
158161 m.mu.Lock()
@@ -258,6 +261,41 @@ func (m *Manager) StartKimiDeviceOAuth() (string, error) {
258261 return url, nil
259262 }
260263
264+// StartXAIDeviceOAuth starts xAI's device-code subscription login.
265+func (m *Manager) StartXAIDeviceOAuth() (string, error) {
266+ ctx, cancel := context.WithCancel(context.Background())
267+ m.mu.Lock()
268+ if m.oauthCancel != nil {
269+ m.oauthCancel()
270+ }
271+ m.oauthCtx = ctx
272+ m.oauthCancel = cancel
273+ m.mu.Unlock()
274+
275+ dev, err := RequestXAIDeviceAuthorization(ctx)
276+ if err != nil {
277+ return "", err
278+ }
279+ loginURL := dev.VerificationURIComplete
280+ go m.maybeOpen(loginURL)
281+ m.emit(Event{Kind: "started", Provider: "xai", Method: "oauth", URL: loginURL})
282+ go func() {
283+ tok, err := PollXAIDeviceToken(ctx, dev)
284+ if err != nil {
285+ if ctx.Err() == nil {
286+ m.emit(Event{Kind: "error", Provider: "xai", Method: "oauth", Message: err.Error()})
287+ }
288+ return
289+ }
290+ if err := m.store.SetOAuth("xai", *tok); err != nil {
291+ m.emit(Event{Kind: "error", Provider: "xai", Method: "oauth", Message: err.Error()})
292+ return
293+ }
294+ m.emit(Event{Kind: "success", Provider: "xai", Method: "oauth"})
295+ }()
296+ return loginURL, nil
297+}
298+
261299 // StartGitHubCopilotDeviceOAuth starts GitHub Copilot's device-code subscription login.
262300 func (m *Manager) StartGitHubCopilotDeviceOAuth() (string, error) {
263301 ctx, cancel := context.WithCancel(context.Background())
@@ -304,6 +342,9 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
304342 if provider == "kimi" {
305343 return m.StartKimiDeviceOAuth()
306344 }
345+ if provider == "xai" {
346+ return m.StartXAIDeviceOAuth()
347+ }
307348 if provider == "github-copilot" {
308349 return m.StartGitHubCopilotDeviceOAuth()
309350 }
@@ -320,7 +361,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
320361 case "deepseek":
321362 return "", fmt.Errorf("deepseek login is api-key only; use api key login")
322363 default:
323- return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, github-copilot, deepseek, or google")
364+ return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, xai, github-copilot, deepseek, or google")
324365 }
325366
326367 pkce, err := NewPKCE()
@@ -136,6 +136,9 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
136 if provider == "kimi" {136 if provider == "kimi" {
137 return m.StartKimiDeviceOAuth()137 return m.StartKimiDeviceOAuth()
138 }138 }
139+ if provider == "xai" {
140+ return m.StartXAIDeviceOAuth()
141+ }
139 if provider == "github-copilot" {142 if provider == "github-copilot" {
140 return m.StartGitHubCopilotDeviceOAuth()143 return m.StartGitHubCopilotDeviceOAuth()
141 }144 }
@@ -152,7 +155,7 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
152 case "deepseek":155 case "deepseek":
153 return "", fmt.Errorf("deepseek login is api-key only; use api key login")156 return "", fmt.Errorf("deepseek login is api-key only; use api key login")
154 default:157 default:
155- return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, github-copilot, deepseek, or google")158+ return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, xai, github-copilot, deepseek, or google")
156 }159 }
157 160
158 m.mu.Lock()161 m.mu.Lock()
@@ -258,6 +261,41 @@ func (m *Manager) StartKimiDeviceOAuth() (string, error) {
258 return url, nil261 return url, nil
259 }262 }
260 263
264+// StartXAIDeviceOAuth starts xAI's device-code subscription login.
265+func (m *Manager) StartXAIDeviceOAuth() (string, error) {
266+ ctx, cancel := context.WithCancel(context.Background())
267+ m.mu.Lock()
268+ if m.oauthCancel != nil {
269+ m.oauthCancel()
270+ }
271+ m.oauthCtx = ctx
272+ m.oauthCancel = cancel
273+ m.mu.Unlock()
274+
275+ dev, err := RequestXAIDeviceAuthorization(ctx)
276+ if err != nil {
277+ return "", err
278+ }
279+ loginURL := dev.VerificationURIComplete
280+ go m.maybeOpen(loginURL)
281+ m.emit(Event{Kind: "started", Provider: "xai", Method: "oauth", URL: loginURL})
282+ go func() {
283+ tok, err := PollXAIDeviceToken(ctx, dev)
284+ if err != nil {
285+ if ctx.Err() == nil {
286+ m.emit(Event{Kind: "error", Provider: "xai", Method: "oauth", Message: err.Error()})
287+ }
288+ return
289+ }
290+ if err := m.store.SetOAuth("xai", *tok); err != nil {
291+ m.emit(Event{Kind: "error", Provider: "xai", Method: "oauth", Message: err.Error()})
292+ return
293+ }
294+ m.emit(Event{Kind: "success", Provider: "xai", Method: "oauth"})
295+ }()
296+ return loginURL, nil
297+}
298+
261 // StartGitHubCopilotDeviceOAuth starts GitHub Copilot's device-code subscription login.299 // StartGitHubCopilotDeviceOAuth starts GitHub Copilot's device-code subscription login.
262 func (m *Manager) StartGitHubCopilotDeviceOAuth() (string, error) {300 func (m *Manager) StartGitHubCopilotDeviceOAuth() (string, error) {
263 ctx, cancel := context.WithCancel(context.Background())301 ctx, cancel := context.WithCancel(context.Background())
@@ -304,6 +342,9 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
304 if provider == "kimi" {342 if provider == "kimi" {
305 return m.StartKimiDeviceOAuth()343 return m.StartKimiDeviceOAuth()
306 }344 }
345+ if provider == "xai" {
346+ return m.StartXAIDeviceOAuth()
347+ }
307 if provider == "github-copilot" {348 if provider == "github-copilot" {
308 return m.StartGitHubCopilotDeviceOAuth()349 return m.StartGitHubCopilotDeviceOAuth()
309 }350 }
@@ -320,7 +361,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
320 case "deepseek":361 case "deepseek":
321 return "", fmt.Errorf("deepseek login is api-key only; use api key login")362 return "", fmt.Errorf("deepseek login is api-key only; use api key login")
322 default:363 default:
323- return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, github-copilot, deepseek, or google")364+ return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, xai, github-copilot, deepseek, or google")
324 }365 }
325 366
326 pkce, err := NewPKCE()367 pkce, err := NewPKCE()
M packages/provider/auth/store.go +2 -2
@@ -1,5 +1,5 @@
1-// Package auth handles credential storage and the two login flows
2-// supported by zot: API key and (experimental) subscription OAuth.
1+// Package auth handles credential storage and the login methods
2+// supported by zot: API keys and provider-specific subscription OAuth.
33 //
44 // All credentials live in $ZOT_HOME/auth.json (mode 0600).
55 package auth
@@ -1,5 +1,5 @@
1-// Package auth handles credential storage and the two login flows1+// Package auth handles credential storage and the login methods
2-// supported by zot: API key and (experimental) subscription OAuth.2+// supported by zot: API keys and provider-specific subscription OAuth.
3 //3 //
4 // All credentials live in $ZOT_HOME/auth.json (mode 0600).4 // All credentials live in $ZOT_HOME/auth.json (mode 0600).
5 package auth5 package auth
A packages/provider/auth/xai.go +186 -0
@@ -0,0 +1,186 @@
1+package auth
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "net/url"
10+ "strings"
11+ "time"
12+)
13+
14+const (
15+ xaiClientID = "b1a00492-073a-47ea-816f-4c329264a828"
16+ xaiScope = "openid profile email offline_access grok-cli:access api:access"
17+)
18+
19+var (
20+ xaiDeviceCodeURL = "https://auth.x.ai/oauth2/device/code"
21+ xaiTokenURL = "https://auth.x.ai/oauth2/token"
22+)
23+
24+type XAIDeviceAuthorization struct {
25+ DeviceCode string `json:"device_code"`
26+ UserCode string `json:"user_code"`
27+ VerificationURI string `json:"verification_uri"`
28+ VerificationURIComplete string `json:"verification_uri_complete"`
29+ ExpiresIn int `json:"expires_in"`
30+ Interval int `json:"interval"`
31+}
32+
33+func RequestXAIDeviceAuthorization(ctx context.Context) (XAIDeviceAuthorization, error) {
34+ values := url.Values{"client_id": {xaiClientID}, "scope": {xaiScope}}
35+ var out XAIDeviceAuthorization
36+ if err := postXAIForm(ctx, xaiDeviceCodeURL, values, &out); err != nil {
37+ return out, fmt.Errorf("xai device authorization: %w", err)
38+ }
39+ if out.DeviceCode == "" || out.UserCode == "" || out.VerificationURI == "" || out.ExpiresIn <= 0 {
40+ return out, fmt.Errorf("xai device authorization: incomplete response")
41+ }
42+ verificationURL := out.VerificationURIComplete
43+ if verificationURL == "" {
44+ verificationURL = out.VerificationURI
45+ }
46+ u, err := url.Parse(verificationURL)
47+ if err != nil || u.Scheme != "https" {
48+ return out, fmt.Errorf("xai device authorization: untrusted verification uri")
49+ }
50+ if out.VerificationURIComplete == "" {
51+ q := u.Query()
52+ q.Set("user_code", out.UserCode)
53+ u.RawQuery = q.Encode()
54+ out.VerificationURIComplete = u.String()
55+ }
56+ return out, nil
57+}
58+
59+func PollXAIDeviceToken(ctx context.Context, dev XAIDeviceAuthorization) (*OAuthToken, error) {
60+ interval := time.Duration(dev.Interval) * time.Second
61+ if interval <= 0 {
62+ interval = 5 * time.Second
63+ }
64+ deadline := time.NewTimer(time.Duration(dev.ExpiresIn) * time.Second)
65+ defer deadline.Stop()
66+
67+ for {
68+ select {
69+ case <-ctx.Done():
70+ return nil, ctx.Err()
71+ case <-deadline.C:
72+ return nil, fmt.Errorf("xai device code expired")
73+ case <-time.After(interval):
74+ }
75+
76+ values := url.Values{
77+ "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
78+ "client_id": {xaiClientID},
79+ "device_code": {dev.DeviceCode},
80+ }
81+ tok, retry, nextInterval, err := requestXAIToken(ctx, values, "")
82+ if err != nil {
83+ return nil, err
84+ }
85+ if tok != nil {
86+ return tok, nil
87+ }
88+ if !retry {
89+ return nil, fmt.Errorf("xai device authorization failed")
90+ }
91+ if nextInterval > 0 {
92+ interval = nextInterval
93+ }
94+ }
95+}
96+
97+func RefreshXAIToken(ctx context.Context, refreshToken string) (*OAuthToken, error) {
98+ values := url.Values{
99+ "grant_type": {"refresh_token"},
100+ "client_id": {xaiClientID},
101+ "refresh_token": {refreshToken},
102+ }
103+ tok, _, _, err := requestXAIToken(ctx, values, refreshToken)
104+ return tok, err
105+}
106+
107+func requestXAIToken(ctx context.Context, values url.Values, previousRefresh string) (*OAuthToken, bool, time.Duration, error) {
108+ var body struct {
109+ AccessToken string `json:"access_token"`
110+ RefreshToken string `json:"refresh_token"`
111+ TokenType string `json:"token_type"`
112+ Scope string `json:"scope"`
113+ ExpiresIn int `json:"expires_in"`
114+ Error string `json:"error"`
115+ ErrorDescription string `json:"error_description"`
116+ Interval int `json:"interval"`
117+ }
118+ status, err := postXAIFormStatus(ctx, xaiTokenURL, values, &body)
119+ if err != nil {
120+ return nil, false, 0, fmt.Errorf("xai token request: %w", err)
121+ }
122+ if status >= 200 && status < 300 && body.AccessToken != "" {
123+ if body.RefreshToken == "" {
124+ body.RefreshToken = previousRefresh
125+ }
126+ expires := body.ExpiresIn
127+ if expires <= 0 {
128+ expires = 3600
129+ }
130+ return &OAuthToken{
131+ AccessToken: body.AccessToken, RefreshToken: body.RefreshToken,
132+ TokenType: body.TokenType, Scope: body.Scope,
133+ Expiry: time.Now().Add(time.Duration(expires)*time.Second - 5*time.Minute),
134+ ClientID: xaiClientID,
135+ }, false, 0, nil
136+ }
137+ switch body.Error {
138+ case "authorization_pending":
139+ return nil, true, 0, nil
140+ case "slow_down":
141+ return nil, true, time.Duration(body.Interval) * time.Second, nil
142+ case "access_denied", "authorization_denied":
143+ return nil, false, 0, fmt.Errorf("xai device authorization was denied")
144+ case "expired_token":
145+ return nil, false, 0, fmt.Errorf("xai device code expired")
146+ default:
147+ detail := strings.TrimSpace(strings.Join([]string{body.Error, body.ErrorDescription}, ": "))
148+ if detail == "" {
149+ detail = http.StatusText(status)
150+ }
151+ return nil, false, 0, fmt.Errorf("xai oauth http %d: %s", status, detail)
152+ }
153+}
154+
155+func postXAIForm(ctx context.Context, endpoint string, values url.Values, out any) error {
156+ status, err := postXAIFormStatus(ctx, endpoint, values, out)
157+ if err != nil {
158+ return err
159+ }
160+ if status < 200 || status >= 300 {
161+ return fmt.Errorf("http %d", status)
162+ }
163+ return nil
164+}
165+
166+func postXAIFormStatus(ctx context.Context, endpoint string, values url.Values, out any) (int, error) {
167+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(values.Encode()))
168+ if err != nil {
169+ return 0, err
170+ }
171+ req.Header.Set("Accept", "application/json")
172+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
173+ resp, err := http.DefaultClient.Do(req)
174+ if err != nil {
175+ return 0, err
176+ }
177+ defer resp.Body.Close()
178+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
179+ if err != nil {
180+ return resp.StatusCode, err
181+ }
182+ if err := json.Unmarshal(body, out); err != nil {
183+ return resp.StatusCode, fmt.Errorf("invalid json: %w", err)
184+ }
185+ return resp.StatusCode, nil
186+}
@@ -0,0 +1,186 @@
1+package auth
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "net/url"
10+ "strings"
11+ "time"
12+)
13+
14+const (
15+ xaiClientID = "b1a00492-073a-47ea-816f-4c329264a828"
16+ xaiScope = "openid profile email offline_access grok-cli:access api:access"
17+)
18+
19+var (
20+ xaiDeviceCodeURL = "https://auth.x.ai/oauth2/device/code"
21+ xaiTokenURL = "https://auth.x.ai/oauth2/token"
22+)
23+
24+type XAIDeviceAuthorization struct {
25+ DeviceCode string `json:"device_code"`
26+ UserCode string `json:"user_code"`
27+ VerificationURI string `json:"verification_uri"`
28+ VerificationURIComplete string `json:"verification_uri_complete"`
29+ ExpiresIn int `json:"expires_in"`
30+ Interval int `json:"interval"`
31+}
32+
33+func RequestXAIDeviceAuthorization(ctx context.Context) (XAIDeviceAuthorization, error) {
34+ values := url.Values{"client_id": {xaiClientID}, "scope": {xaiScope}}
35+ var out XAIDeviceAuthorization
36+ if err := postXAIForm(ctx, xaiDeviceCodeURL, values, &out); err != nil {
37+ return out, fmt.Errorf("xai device authorization: %w", err)
38+ }
39+ if out.DeviceCode == "" || out.UserCode == "" || out.VerificationURI == "" || out.ExpiresIn <= 0 {
40+ return out, fmt.Errorf("xai device authorization: incomplete response")
41+ }
42+ verificationURL := out.VerificationURIComplete
43+ if verificationURL == "" {
44+ verificationURL = out.VerificationURI
45+ }
46+ u, err := url.Parse(verificationURL)
47+ if err != nil || u.Scheme != "https" {
48+ return out, fmt.Errorf("xai device authorization: untrusted verification uri")
49+ }
50+ if out.VerificationURIComplete == "" {
51+ q := u.Query()
52+ q.Set("user_code", out.UserCode)
53+ u.RawQuery = q.Encode()
54+ out.VerificationURIComplete = u.String()
55+ }
56+ return out, nil
57+}
58+
59+func PollXAIDeviceToken(ctx context.Context, dev XAIDeviceAuthorization) (*OAuthToken, error) {
60+ interval := time.Duration(dev.Interval) * time.Second
61+ if interval <= 0 {
62+ interval = 5 * time.Second
63+ }
64+ deadline := time.NewTimer(time.Duration(dev.ExpiresIn) * time.Second)
65+ defer deadline.Stop()
66+
67+ for {
68+ select {
69+ case <-ctx.Done():
70+ return nil, ctx.Err()
71+ case <-deadline.C:
72+ return nil, fmt.Errorf("xai device code expired")
73+ case <-time.After(interval):
74+ }
75+
76+ values := url.Values{
77+ "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
78+ "client_id": {xaiClientID},
79+ "device_code": {dev.DeviceCode},
80+ }
81+ tok, retry, nextInterval, err := requestXAIToken(ctx, values, "")
82+ if err != nil {
83+ return nil, err
84+ }
85+ if tok != nil {
86+ return tok, nil
87+ }
88+ if !retry {
89+ return nil, fmt.Errorf("xai device authorization failed")
90+ }
91+ if nextInterval > 0 {
92+ interval = nextInterval
93+ }
94+ }
95+}
96+
97+func RefreshXAIToken(ctx context.Context, refreshToken string) (*OAuthToken, error) {
98+ values := url.Values{
99+ "grant_type": {"refresh_token"},
100+ "client_id": {xaiClientID},
101+ "refresh_token": {refreshToken},
102+ }
103+ tok, _, _, err := requestXAIToken(ctx, values, refreshToken)
104+ return tok, err
105+}
106+
107+func requestXAIToken(ctx context.Context, values url.Values, previousRefresh string) (*OAuthToken, bool, time.Duration, error) {
108+ var body struct {
109+ AccessToken string `json:"access_token"`
110+ RefreshToken string `json:"refresh_token"`
111+ TokenType string `json:"token_type"`
112+ Scope string `json:"scope"`
113+ ExpiresIn int `json:"expires_in"`
114+ Error string `json:"error"`
115+ ErrorDescription string `json:"error_description"`
116+ Interval int `json:"interval"`
117+ }
118+ status, err := postXAIFormStatus(ctx, xaiTokenURL, values, &body)
119+ if err != nil {
120+ return nil, false, 0, fmt.Errorf("xai token request: %w", err)
121+ }
122+ if status >= 200 && status < 300 && body.AccessToken != "" {
123+ if body.RefreshToken == "" {
124+ body.RefreshToken = previousRefresh
125+ }
126+ expires := body.ExpiresIn
127+ if expires <= 0 {
128+ expires = 3600
129+ }
130+ return &OAuthToken{
131+ AccessToken: body.AccessToken, RefreshToken: body.RefreshToken,
132+ TokenType: body.TokenType, Scope: body.Scope,
133+ Expiry: time.Now().Add(time.Duration(expires)*time.Second - 5*time.Minute),
134+ ClientID: xaiClientID,
135+ }, false, 0, nil
136+ }
137+ switch body.Error {
138+ case "authorization_pending":
139+ return nil, true, 0, nil
140+ case "slow_down":
141+ return nil, true, time.Duration(body.Interval) * time.Second, nil
142+ case "access_denied", "authorization_denied":
143+ return nil, false, 0, fmt.Errorf("xai device authorization was denied")
144+ case "expired_token":
145+ return nil, false, 0, fmt.Errorf("xai device code expired")
146+ default:
147+ detail := strings.TrimSpace(strings.Join([]string{body.Error, body.ErrorDescription}, ": "))
148+ if detail == "" {
149+ detail = http.StatusText(status)
150+ }
151+ return nil, false, 0, fmt.Errorf("xai oauth http %d: %s", status, detail)
152+ }
153+}
154+
155+func postXAIForm(ctx context.Context, endpoint string, values url.Values, out any) error {
156+ status, err := postXAIFormStatus(ctx, endpoint, values, out)
157+ if err != nil {
158+ return err
159+ }
160+ if status < 200 || status >= 300 {
161+ return fmt.Errorf("http %d", status)
162+ }
163+ return nil
164+}
165+
166+func postXAIFormStatus(ctx context.Context, endpoint string, values url.Values, out any) (int, error) {
167+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(values.Encode()))
168+ if err != nil {
169+ return 0, err
170+ }
171+ req.Header.Set("Accept", "application/json")
172+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
173+ resp, err := http.DefaultClient.Do(req)
174+ if err != nil {
175+ return 0, err
176+ }
177+ defer resp.Body.Close()
178+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
179+ if err != nil {
180+ return resp.StatusCode, err
181+ }
182+ if err := json.Unmarshal(body, out); err != nil {
183+ return resp.StatusCode, fmt.Errorf("invalid json: %w", err)
184+ }
185+ return resp.StatusCode, nil
186+}
A packages/provider/auth/xai_test.go +70 -0
@@ -0,0 +1,70 @@
1+package auth
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "net/url"
9+ "testing"
10+)
11+
12+func TestRequestXAIDeviceAuthorizationBuildsPrefilledURL(t *testing.T) {
13+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
14+ if err := r.ParseForm(); err != nil {
15+ t.Fatal(err)
16+ }
17+ if r.Form.Get("client_id") != xaiClientID || r.Form.Get("scope") != xaiScope {
18+ t.Fatalf("unexpected form: %v", r.Form)
19+ }
20+ _ = json.NewEncoder(w).Encode(map[string]any{
21+ "device_code": "device", "user_code": "AB CD",
22+ "verification_uri": "https://accounts.example/activate",
23+ "expires_in": 600,
24+ })
25+ }))
26+ defer server.Close()
27+
28+ old := xaiDeviceCodeURL
29+ xaiDeviceCodeURL = server.URL
30+ t.Cleanup(func() { xaiDeviceCodeURL = old })
31+
32+ device, err := RequestXAIDeviceAuthorization(context.Background())
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ u, err := url.Parse(device.VerificationURIComplete)
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ if got := u.Query().Get("user_code"); got != "AB CD" {
41+ t.Fatalf("user_code = %q", got)
42+ }
43+}
44+
45+func TestRefreshXAITokenPreservesRefreshToken(t *testing.T) {
46+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
47+ if err := r.ParseForm(); err != nil {
48+ t.Fatal(err)
49+ }
50+ if r.Form.Get("grant_type") != "refresh_token" || r.Form.Get("refresh_token") != "old-refresh" {
51+ t.Fatalf("unexpected form: %v", r.Form)
52+ }
53+ _ = json.NewEncoder(w).Encode(map[string]any{
54+ "access_token": "new-access", "expires_in": 3600,
55+ })
56+ }))
57+ defer server.Close()
58+
59+ old := xaiTokenURL
60+ xaiTokenURL = server.URL
61+ t.Cleanup(func() { xaiTokenURL = old })
62+
63+ token, err := RefreshXAIToken(context.Background(), "old-refresh")
64+ if err != nil {
65+ t.Fatal(err)
66+ }
67+ if token.AccessToken != "new-access" || token.RefreshToken != "old-refresh" {
68+ t.Fatalf("token = %+v", token)
69+ }
70+}
@@ -0,0 +1,70 @@
1+package auth
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "net/url"
9+ "testing"
10+)
11+
12+func TestRequestXAIDeviceAuthorizationBuildsPrefilledURL(t *testing.T) {
13+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
14+ if err := r.ParseForm(); err != nil {
15+ t.Fatal(err)
16+ }
17+ if r.Form.Get("client_id") != xaiClientID || r.Form.Get("scope") != xaiScope {
18+ t.Fatalf("unexpected form: %v", r.Form)
19+ }
20+ _ = json.NewEncoder(w).Encode(map[string]any{
21+ "device_code": "device", "user_code": "AB CD",
22+ "verification_uri": "https://accounts.example/activate",
23+ "expires_in": 600,
24+ })
25+ }))
26+ defer server.Close()
27+
28+ old := xaiDeviceCodeURL
29+ xaiDeviceCodeURL = server.URL
30+ t.Cleanup(func() { xaiDeviceCodeURL = old })
31+
32+ device, err := RequestXAIDeviceAuthorization(context.Background())
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ u, err := url.Parse(device.VerificationURIComplete)
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ if got := u.Query().Get("user_code"); got != "AB CD" {
41+ t.Fatalf("user_code = %q", got)
42+ }
43+}
44+
45+func TestRefreshXAITokenPreservesRefreshToken(t *testing.T) {
46+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
47+ if err := r.ParseForm(); err != nil {
48+ t.Fatal(err)
49+ }
50+ if r.Form.Get("grant_type") != "refresh_token" || r.Form.Get("refresh_token") != "old-refresh" {
51+ t.Fatalf("unexpected form: %v", r.Form)
52+ }
53+ _ = json.NewEncoder(w).Encode(map[string]any{
54+ "access_token": "new-access", "expires_in": 3600,
55+ })
56+ }))
57+ defer server.Close()
58+
59+ old := xaiTokenURL
60+ xaiTokenURL = server.URL
61+ t.Cleanup(func() { xaiTokenURL = old })
62+
63+ token, err := RefreshXAIToken(context.Background(), "old-refresh")
64+ if err != nil {
65+ t.Fatal(err)
66+ }
67+ if token.AccessToken != "new-access" || token.RefreshToken != "old-refresh" {
68+ t.Fatalf("token = %+v", token)
69+ }
70+}
A packages/provider/deferred_tools_test.go +93 -0
@@ -0,0 +1,93 @@
1+package provider
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+)
7+
8+func TestKimiK3DeferredToolsLoadAtToolResult(t *testing.T) {
9+ client := NewMoonshot("token", "").(*openaiClient)
10+ tools := []Tool{
11+ {Name: "search_tools", Schema: json.RawMessage(`{"type":"object"}`)},
12+ {Name: "lookup_weather", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},
13+ }
14+ wire, err := client.buildRequest(Request{
15+ Model: "kimi-k3",
16+ Tools: tools,
17+ Messages: []Message{
18+ {Role: RoleUser, Content: []Content{TextBlock{Text: "weather"}}},
19+ {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "call-1", Name: "search_tools", Arguments: json.RawMessage(`{}`)}}},
20+ {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call-1", Content: []Content{TextBlock{Text: "found"}}}}, AddedToolNames: []string{"lookup_weather"}},
21+ },
22+ })
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ if len(wire.Tools) != 1 || wire.Tools[0].Function.Name != "search_tools" {
27+ t.Fatalf("top-level tools = %+v, want only search_tools", wire.Tools)
28+ }
29+ var loaded []oaiTool
30+ for _, message := range wire.Messages {
31+ if message.Role == "system" && len(message.Tools) > 0 {
32+ loaded = message.Tools
33+ }
34+ }
35+ if len(loaded) != 1 || loaded[0].Function.Name != "lookup_weather" {
36+ t.Fatalf("loaded tools = %+v, want lookup_weather", loaded)
37+ }
38+}
39+
40+func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {
41+ client := NewOpenAI("token", "").(*openaiClient)
42+ tools := []Tool{
43+ {Name: "base", Schema: json.RawMessage(`{"type":"object"}`)},
44+ {Name: "late", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},
45+ }
46+ wire, err := client.buildRequest(Request{Model: "gpt-5", Tools: tools})
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+ if len(wire.Tools) != 1 || wire.Tools[0].Function.Name != "base" {
51+ t.Fatalf("initial tools = %+v", wire.Tools)
52+ }
53+
54+ wire, err = client.buildRequest(Request{
55+ Model: "gpt-5",
56+ Tools: tools,
57+ Messages: []Message{{Role: RoleTool, AddedToolNames: []string{"late"}}},
58+ })
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ if len(wire.Tools) != 2 {
63+ t.Fatalf("activated tools = %+v, want both definitions", wire.Tools)
64+ }
65+}
66+
67+func TestKimiK3AndGrok45CatalogMetadata(t *testing.T) {
68+ for _, tc := range []struct {
69+ provider string
70+ model string
71+ }{
72+ {"kimi", "k3"},
73+ {"moonshotai", "kimi-k3"},
74+ {"moonshotai-cn", "kimi-k3"},
75+ {"openrouter", "moonshotai/kimi-k3"},
76+ {"vercel-ai-gateway", "moonshotai/kimi-k3"},
77+ } {
78+ m, err := FindModel(tc.provider, tc.model)
79+ if err != nil {
80+ t.Fatalf("%s/%s: %v", tc.provider, tc.model, err)
81+ }
82+ if m.MaxOutput != 131072 || !m.Reasoning {
83+ t.Fatalf("%s/%s metadata = %+v", tc.provider, tc.model, m)
84+ }
85+ }
86+ m, err := FindModel("xai", "grok-4.5")
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ if !m.Reasoning || m.MaxOutput != 30000 {
91+ t.Fatalf("xai/grok-4.5 metadata = %+v", m)
92+ }
93+}
@@ -0,0 +1,93 @@
1+package provider
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+)
7+
8+func TestKimiK3DeferredToolsLoadAtToolResult(t *testing.T) {
9+ client := NewMoonshot("token", "").(*openaiClient)
10+ tools := []Tool{
11+ {Name: "search_tools", Schema: json.RawMessage(`{"type":"object"}`)},
12+ {Name: "lookup_weather", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},
13+ }
14+ wire, err := client.buildRequest(Request{
15+ Model: "kimi-k3",
16+ Tools: tools,
17+ Messages: []Message{
18+ {Role: RoleUser, Content: []Content{TextBlock{Text: "weather"}}},
19+ {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "call-1", Name: "search_tools", Arguments: json.RawMessage(`{}`)}}},
20+ {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call-1", Content: []Content{TextBlock{Text: "found"}}}}, AddedToolNames: []string{"lookup_weather"}},
21+ },
22+ })
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ if len(wire.Tools) != 1 || wire.Tools[0].Function.Name != "search_tools" {
27+ t.Fatalf("top-level tools = %+v, want only search_tools", wire.Tools)
28+ }
29+ var loaded []oaiTool
30+ for _, message := range wire.Messages {
31+ if message.Role == "system" && len(message.Tools) > 0 {
32+ loaded = message.Tools
33+ }
34+ }
35+ if len(loaded) != 1 || loaded[0].Function.Name != "lookup_weather" {
36+ t.Fatalf("loaded tools = %+v, want lookup_weather", loaded)
37+ }
38+}
39+
40+func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {
41+ client := NewOpenAI("token", "").(*openaiClient)
42+ tools := []Tool{
43+ {Name: "base", Schema: json.RawMessage(`{"type":"object"}`)},
44+ {Name: "late", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},
45+ }
46+ wire, err := client.buildRequest(Request{Model: "gpt-5", Tools: tools})
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+ if len(wire.Tools) != 1 || wire.Tools[0].Function.Name != "base" {
51+ t.Fatalf("initial tools = %+v", wire.Tools)
52+ }
53+
54+ wire, err = client.buildRequest(Request{
55+ Model: "gpt-5",
56+ Tools: tools,
57+ Messages: []Message{{Role: RoleTool, AddedToolNames: []string{"late"}}},
58+ })
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ if len(wire.Tools) != 2 {
63+ t.Fatalf("activated tools = %+v, want both definitions", wire.Tools)
64+ }
65+}
66+
67+func TestKimiK3AndGrok45CatalogMetadata(t *testing.T) {
68+ for _, tc := range []struct {
69+ provider string
70+ model string
71+ }{
72+ {"kimi", "k3"},
73+ {"moonshotai", "kimi-k3"},
74+ {"moonshotai-cn", "kimi-k3"},
75+ {"openrouter", "moonshotai/kimi-k3"},
76+ {"vercel-ai-gateway", "moonshotai/kimi-k3"},
77+ } {
78+ m, err := FindModel(tc.provider, tc.model)
79+ if err != nil {
80+ t.Fatalf("%s/%s: %v", tc.provider, tc.model, err)
81+ }
82+ if m.MaxOutput != 131072 || !m.Reasoning {
83+ t.Fatalf("%s/%s metadata = %+v", tc.provider, tc.model, m)
84+ }
85+ }
86+ m, err := FindModel("xai", "grok-4.5")
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ if !m.Reasoning || m.MaxOutput != 30000 {
91+ t.Fatalf("xai/grok-4.5 metadata = %+v", m)
92+ }
93+}
M packages/provider/extra_models.go +28 -0
@@ -18,6 +18,34 @@ var supplementCatalog = []Model{
1818 {Provider: "kimi", ID: "kimi-k2-thinking", DisplayName: "Kimi K2 Thinking",
1919 ContextWindow: 262144, MaxOutput: 32000, Reasoning: true,
2020 BaseURL: "https://api.kimi.com/coding"},
21+ {Provider: "kimi", ID: "k3", DisplayName: "Kimi K3",
22+ ContextWindow: 262144, MaxOutput: 131072, Reasoning: true,
23+ PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3,
24+ BaseURL: "https://api.kimi.com/coding"},
25+
26+ // ----- Kimi K3 on OpenAI-compatible providers -----
27+ {Provider: "moonshotai", ID: "kimi-k3", DisplayName: "Kimi K3",
28+ ContextWindow: 262144, MaxOutput: 131072, Reasoning: true,
29+ PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3,
30+ BaseURL: "https://api.moonshot.ai/v1"},
31+ {Provider: "moonshotai-cn", ID: "kimi-k3", DisplayName: "Kimi K3",
32+ ContextWindow: 262144, MaxOutput: 131072, Reasoning: true,
33+ PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3,
34+ BaseURL: "https://api.moonshot.cn/v1"},
35+ {Provider: "openrouter", ID: "moonshotai/kimi-k3", DisplayName: "Kimi K3",
36+ ContextWindow: 262144, MaxOutput: 131072, Reasoning: true,
37+ PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3,
38+ BaseURL: "https://openrouter.ai/api/v1"},
39+ {Provider: "vercel-ai-gateway", ID: "moonshotai/kimi-k3", DisplayName: "Kimi K3",
40+ ContextWindow: 262144, MaxOutput: 131072, Reasoning: true,
41+ PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3,
42+ BaseURL: "https://ai-gateway.vercel.sh"},
43+
44+ // ----- xAI Responses API -----
45+ {Provider: "xai", ID: "grok-4.5", DisplayName: "Grok 4.5",
46+ ContextWindow: 2000000, MaxOutput: 30000, Reasoning: true,
47+ PriceInput: 1.25, PriceOutput: 2.5, PriceCacheRead: 0.2,
48+ BaseURL: "https://api.x.ai/v1"},
2149
2250 // ----- openai-responses (public OpenAI Responses API) -----
2351 {Provider: "openai-responses", ID: "gpt-5", DisplayName: "GPT-5 (Responses)",
@@ -18,6 +18,34 @@ var supplementCatalog = []Model{
18 {Provider: "kimi", ID: "kimi-k2-thinking", DisplayName: "Kimi K2 Thinking",18 {Provider: "kimi", ID: "kimi-k2-thinking", DisplayName: "Kimi K2 Thinking",
19 ContextWindow: 262144, MaxOutput: 32000, Reasoning: true,19 ContextWindow: 262144, MaxOutput: 32000, Reasoning: true,
20 BaseURL: "https://api.kimi.com/coding"},20 BaseURL: "https://api.kimi.com/coding"},
21+ {Provider: "kimi", ID: "k3", DisplayName: "Kimi K3",
22+ ContextWindow: 262144, MaxOutput: 131072, Reasoning: true,
23+ PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3,
24+ BaseURL: "https://api.kimi.com/coding"},
25+
26+ // ----- Kimi K3 on OpenAI-compatible providers -----
27+ {Provider: "moonshotai", ID: "kimi-k3", DisplayName: "Kimi K3",
28+ ContextWindow: 262144, MaxOutput: 131072, Reasoning: true,
29+ PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3,
30+ BaseURL: "https://api.moonshot.ai/v1"},
31+ {Provider: "moonshotai-cn", ID: "kimi-k3", DisplayName: "Kimi K3",
32+ ContextWindow: 262144, MaxOutput: 131072, Reasoning: true,
33+ PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3,
34+ BaseURL: "https://api.moonshot.cn/v1"},
35+ {Provider: "openrouter", ID: "moonshotai/kimi-k3", DisplayName: "Kimi K3",
36+ ContextWindow: 262144, MaxOutput: 131072, Reasoning: true,
37+ PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3,
38+ BaseURL: "https://openrouter.ai/api/v1"},
39+ {Provider: "vercel-ai-gateway", ID: "moonshotai/kimi-k3", DisplayName: "Kimi K3",
40+ ContextWindow: 262144, MaxOutput: 131072, Reasoning: true,
41+ PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3,
42+ BaseURL: "https://ai-gateway.vercel.sh"},
43+
44+ // ----- xAI Responses API -----
45+ {Provider: "xai", ID: "grok-4.5", DisplayName: "Grok 4.5",
46+ ContextWindow: 2000000, MaxOutput: 30000, Reasoning: true,
47+ PriceInput: 1.25, PriceOutput: 2.5, PriceCacheRead: 0.2,
48+ BaseURL: "https://api.x.ai/v1"},
21 49
22 // ----- openai-responses (public OpenAI Responses API) -----50 // ----- openai-responses (public OpenAI Responses API) -----
23 {Provider: "openai-responses", ID: "gpt-5", DisplayName: "GPT-5 (Responses)",51 {Provider: "openai-responses", ID: "gpt-5", DisplayName: "GPT-5 (Responses)",
M packages/provider/openai.go +42 -6
@@ -144,6 +144,7 @@ type oaiMessage struct {
144144 Name string `json:"name,omitempty"`
145145 ToolCalls []oaiToolCall `json:"tool_calls,omitempty"`
146146 ToolCallID string `json:"tool_call_id,omitempty"`
147+ Tools []oaiTool `json:"tools,omitempty"`
147148 // ReasoningContent carries the model's chain-of-thought summary
148149 // alongside an assistant tool-call message. Required by Kimi's
149150 // chat completions endpoint when thinking is enabled and the
@@ -267,6 +268,18 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
267268 out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System})
268269 }
269270
271+ deferredMode := isKimiDeferredModel(req.Model)
272+ toolByName := make(map[string]Tool, len(req.Tools))
273+ activatedTools := make(map[string]bool)
274+ for _, t := range req.Tools {
275+ toolByName[t.Name] = t
276+ }
277+ for _, msg := range req.Messages {
278+ for _, name := range msg.AddedToolNames {
279+ activatedTools[name] = true
280+ }
281+ }
282+
270283 // DeepSeek's chat-completions API rejects the multimodal content
271284 // schema (parts arrays containing image_url). Force every user/tool
272285 // message to a plain string and silently drop image blocks for
@@ -345,15 +358,24 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
345358 }
346359 }
347360 }
361+ if deferredMode {
362+ var loaded []oaiTool
363+ for _, name := range msg.AddedToolNames {
364+ if t, ok := toolByName[name]; ok && t.Deferred {
365+ loaded = append(loaded, makeOAITool(t))
366+ }
367+ }
368+ if len(loaded) > 0 {
369+ out.Messages = append(out.Messages, oaiMessage{Role: "system", Tools: loaded})
370+ }
371+ }
348372 }
349373
350374 for _, t := range req.Tools {
351- var tool oaiTool
352- tool.Type = "function"
353- tool.Function.Name = t.Name
354- tool.Function.Description = t.Description
355- tool.Function.Parameters = t.Schema
356- out.Tools = append(out.Tools, tool)
375+ if t.Deferred && (deferredMode || !activatedTools[t.Name]) {
376+ continue
377+ }
378+ out.Tools = append(out.Tools, makeOAITool(t))
357379 }
358380 if len(out.Tools) > 0 {
359381 out.ToolChoice = "auto"
@@ -362,6 +384,20 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
362384 return out, nil
363385 }
364386
387+func isKimiDeferredModel(model string) bool {
388+ model = strings.ToLower(model)
389+ return model == "kimi-k3" || strings.HasSuffix(model, "/kimi-k3")
390+}
391+
392+func makeOAITool(t Tool) oaiTool {
393+ var tool oaiTool
394+ tool.Type = "function"
395+ tool.Function.Name = t.Name
396+ tool.Function.Description = t.Description
397+ tool.Function.Parameters = t.Schema
398+ return tool
399+}
400+
365401 func buildOAIUserContent(blocks []Content, textOnly bool) interface{} {
366402 hasImage := false
367403 for _, b := range blocks {
@@ -144,6 +144,7 @@ type oaiMessage struct {
144 Name string `json:"name,omitempty"`144 Name string `json:"name,omitempty"`
145 ToolCalls []oaiToolCall `json:"tool_calls,omitempty"`145 ToolCalls []oaiToolCall `json:"tool_calls,omitempty"`
146 ToolCallID string `json:"tool_call_id,omitempty"`146 ToolCallID string `json:"tool_call_id,omitempty"`
147+ Tools []oaiTool `json:"tools,omitempty"`
147 // ReasoningContent carries the model's chain-of-thought summary148 // ReasoningContent carries the model's chain-of-thought summary
148 // alongside an assistant tool-call message. Required by Kimi's149 // alongside an assistant tool-call message. Required by Kimi's
149 // chat completions endpoint when thinking is enabled and the150 // chat completions endpoint when thinking is enabled and the
@@ -267,6 +268,18 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
267 out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System})268 out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System})
268 }269 }
269 270
271+ deferredMode := isKimiDeferredModel(req.Model)
272+ toolByName := make(map[string]Tool, len(req.Tools))
273+ activatedTools := make(map[string]bool)
274+ for _, t := range req.Tools {
275+ toolByName[t.Name] = t
276+ }
277+ for _, msg := range req.Messages {
278+ for _, name := range msg.AddedToolNames {
279+ activatedTools[name] = true
280+ }
281+ }
282+
270 // DeepSeek's chat-completions API rejects the multimodal content283 // DeepSeek's chat-completions API rejects the multimodal content
271 // schema (parts arrays containing image_url). Force every user/tool284 // schema (parts arrays containing image_url). Force every user/tool
272 // message to a plain string and silently drop image blocks for285 // message to a plain string and silently drop image blocks for
@@ -345,15 +358,24 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
345 }358 }
346 }359 }
347 }360 }
361+ if deferredMode {
362+ var loaded []oaiTool
363+ for _, name := range msg.AddedToolNames {
364+ if t, ok := toolByName[name]; ok && t.Deferred {
365+ loaded = append(loaded, makeOAITool(t))
366+ }
367+ }
368+ if len(loaded) > 0 {
369+ out.Messages = append(out.Messages, oaiMessage{Role: "system", Tools: loaded})
370+ }
371+ }
348 }372 }
349 373
350 for _, t := range req.Tools {374 for _, t := range req.Tools {
351- var tool oaiTool375+ if t.Deferred && (deferredMode || !activatedTools[t.Name]) {
352- tool.Type = "function"376+ continue
353- tool.Function.Name = t.Name377+ }
354- tool.Function.Description = t.Description378+ out.Tools = append(out.Tools, makeOAITool(t))
355- tool.Function.Parameters = t.Schema
356- out.Tools = append(out.Tools, tool)
357 }379 }
358 if len(out.Tools) > 0 {380 if len(out.Tools) > 0 {
359 out.ToolChoice = "auto"381 out.ToolChoice = "auto"
@@ -362,6 +384,20 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
362 return out, nil384 return out, nil
363 }385 }
364 386
387+func isKimiDeferredModel(model string) bool {
388+ model = strings.ToLower(model)
389+ return model == "kimi-k3" || strings.HasSuffix(model, "/kimi-k3")
390+}
391+
392+func makeOAITool(t Tool) oaiTool {
393+ var tool oaiTool
394+ tool.Type = "function"
395+ tool.Function.Name = t.Name
396+ tool.Function.Description = t.Description
397+ tool.Function.Parameters = t.Schema
398+ return tool
399+}
400+
365 func buildOAIUserContent(blocks []Content, textOnly bool) interface{} {401 func buildOAIUserContent(blocks []Content, textOnly bool) interface{} {
366 hasImage := false402 hasImage := false
367 for _, b := range blocks {403 for _, b := range blocks {
M packages/provider/provider.go +7 -4
@@ -114,10 +114,11 @@ func RepairOrphanedToolResults(msgs []Message) []Message {
114114
115115 // Message is a single turn in the conversation.
116116 type Message struct {
117- Role Role `json:"role"`
118- Content []Content `json:"content"`
119- Time time.Time `json:"time"`
120- Meta map[string]string `json:"meta,omitempty"`
117+ Role Role `json:"role"`
118+ Content []Content `json:"content"`
119+ Time time.Time `json:"time"`
120+ Meta map[string]string `json:"meta,omitempty"`
121+ AddedToolNames []string `json:"added_tool_names,omitempty"`
121122 }
122123
123124 // Tool is a tool definition advertised to the LLM.
@@ -125,6 +126,8 @@ type Tool struct {
125126 Name string `json:"name"`
126127 Description string `json:"description"`
127128 Schema json.RawMessage `json:"schema"` // JSON Schema for arguments
129+ // Deferred hides the definition until a tool result activates it.
130+ Deferred bool `json:"deferred,omitempty"`
128131 }
129132
130133 // Usage aggregates token counts and cost for a turn.
@@ -114,10 +114,11 @@ func RepairOrphanedToolResults(msgs []Message) []Message {
114 114
115 // Message is a single turn in the conversation.115 // Message is a single turn in the conversation.
116 type Message struct {116 type Message struct {
117- Role Role `json:"role"`117+ Role Role `json:"role"`
118- Content []Content `json:"content"`118+ Content []Content `json:"content"`
119- Time time.Time `json:"time"`119+ Time time.Time `json:"time"`
120- Meta map[string]string `json:"meta,omitempty"`120+ Meta map[string]string `json:"meta,omitempty"`
121+ AddedToolNames []string `json:"added_tool_names,omitempty"`
121 }122 }
122 123
123 // Tool is a tool definition advertised to the LLM.124 // Tool is a tool definition advertised to the LLM.
@@ -125,6 +126,8 @@ type Tool struct {
125 Name string `json:"name"`126 Name string `json:"name"`
126 Description string `json:"description"`127 Description string `json:"description"`
127 Schema json.RawMessage `json:"schema"` // JSON Schema for arguments128 Schema json.RawMessage `json:"schema"` // JSON Schema for arguments
129+ // Deferred hides the definition until a tool result activates it.
130+ Deferred bool `json:"deferred,omitempty"`
128 }131 }
129 132
130 // Usage aggregates token counts and cost for a turn.133 // Usage aggregates token counts and cost for a turn.

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close