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 | |||
| 90 | 90 | Run `zot` and type `/login`. Pick one of two methods: | |
| 91 | 91 | ||
| 92 | 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 | 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 | 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 | 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 | 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 | 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 | 103 | ### 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 | 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 | 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 | 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 | 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 | 388 | - **Cloud/platform providers**: Amazon Bedrock, Google Vertex AI, Azure OpenAI, Cloudflare Workers AI, Cloudflare AI Gateway. | |
| 388 | 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 | 463 | ### 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 | 467 | ```bash | |
| 467 | 468 | zot --provider kimi | |
| @@ -488,8 +489,14 @@ For direct Moonshot API keys or a custom compatible endpoint: | |||
| 488 | 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 | 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 | 500 | ### DeepSeek | |
| 494 | 501 | ||
| 495 | 502 | 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 refresh | 103 | ### 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 Code | 463 | ### 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 | ```bash | 467 | ```bash | ||
| 467 | zot --provider kimi | 468 | 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 | ### DeepSeek | 500 | ### 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`, | |||
| 204 | 204 | `write`, `edit`, `bash`, `skill`). Conflicts are silently shadowed by | |
| 205 | 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 | 217 | #### `ready` | |
| 208 | 218 | ||
| 209 | 219 | Sentinel telling zot "all initial registrations are flushed". Send it | |
| @@ -219,7 +229,8 @@ agent's tool registry without racing the registration window. | |||
| 219 | 229 | Reply to a `tool_call` from the host. `content[]` is a list of | |
| 220 | 230 | message blocks; each block is `{"type":"text","text":"..."}` or | |
| 221 | 231 | `{"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 | 235 | ```json | |
| 225 | 236 | {"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 by | 204 | `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 it | 219 | 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 of | 229 | Reply to a `tool_call` from the host. `content[]` is a list of | ||
| 220 | message blocks; each block is `{"type":"text","text":"..."}` or | 230 | message blocks; each block is `{"type":"text","text":"..."}` or | ||
| 221 | `{"type":"image","mime_type":"image/png","data":"<base64>"}`. Set | 231 | `{"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 | ```json | 235 | ```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: | |||
| 35 | 35 | | Anthropic | Claude Pro/Max OAuth credentials. | | |
| 36 | 36 | | OpenAI Codex | ChatGPT Plus/Pro Codex subscription route. Separate from the OpenAI API-key provider. | | |
| 37 | 37 | | Kimi | Kimi subscription login. | | |
| 38 | + | | xAI | SuperGrok or X Premium device-code login. The browser URL is prefilled with the device code. | | |
| 38 | 39 | | GitHub Copilot | GitHub Copilot token flow. | | |
| 39 | 40 | ||
| 40 | 41 | 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 is | 41 | 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 { | |||
| 112 | 112 | Name string | |
| 113 | 113 | Description string | |
| 114 | 114 | Schema []byte | |
| 115 | + | Deferred bool | |
| 115 | 116 | } | |
| 116 | 117 | ||
| 117 | 118 | // toolSummariesFromRegistry rebuilds the system-prompt tool list | |
| @@ -120,6 +121,9 @@ type ExtensionToolInfo struct { | |||
| 120 | 121 | func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary { | |
| 121 | 122 | out := make([]ToolSummary, 0, len(reg)) | |
| 122 | 123 | for name, t := range reg { | |
| 124 | + | if d, ok := t.(interface{ Deferred() bool }); ok && d.Deferred() { | |
| 125 | + | continue | |
| 126 | + | } | |
| 123 | 127 | desc := t.Description() | |
| 124 | 128 | if d, ok := cached[name]; ok && d != "" { | |
| 125 | 129 | desc = d | |
| @@ -160,7 +164,7 @@ func defaultModelForProvider(prov string) string { | |||
| 160 | 164 | case "groq": | |
| 161 | 165 | return "llama-3.3-70b-versatile" | |
| 162 | 166 | case "xai": | |
| 163 | - | return "grok-code-fast-1" | |
| 167 | + | return "grok-4.5" | |
| 164 | 168 | case "together": | |
| 165 | 169 | return "Qwen/Qwen3-Coder-480B-A35B-Instruct" | |
| 166 | 170 | case "huggingface": | |
| @@ -773,7 +777,16 @@ func (r Resolved) NewClient() provider.Client { | |||
| 773 | 777 | case "groq": | |
| 774 | 778 | return wrap(provider.NewGroq(r.Credential, r.BaseURL)) | |
| 775 | 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 | 790 | case "together": | |
| 778 | 791 | return wrap(provider.NewTogether(r.Credential, r.BaseURL)) | |
| 779 | 792 | case "huggingface": | |
| @@ -868,6 +881,11 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client { | |||
| 868 | 881 | case "kimi": | |
| 869 | 882 | // anthropic-messages on api.kimi.com/coding. | |
| 870 | 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 | 889 | default: | |
| 872 | 890 | return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL)) | |
| 873 | 891 | } | |
| @@ -112,6 +112,7 @@ type ExtensionToolInfo struct { | |||||
| 112 | Name string | 112 | Name string | ||
| 113 | Description string | 113 | Description string | ||
| 114 | Schema []byte | 114 | Schema []byte | ||
| 115 | + | Deferred bool | |||
| 115 | } | 116 | } | ||
| 116 | 117 | ||||
| 117 | // toolSummariesFromRegistry rebuilds the system-prompt tool list | 118 | // 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 = d | 129 | 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) { | |||
| 406 | 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 | 415 | func assertDefaultTransportStillSecure(t *testing.T) { | |
| 410 | 416 | t.Helper() | |
| 411 | 417 | 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 { | |||
| 106 | 106 | Name: t.Name, | |
| 107 | 107 | Description: t.Description, | |
| 108 | 108 | Schema: t.Schema, | |
| 109 | + | Deferred: t.Deferred, | |
| 109 | 110 | } | |
| 110 | 111 | } | |
| 111 | 112 | return out | |
| @@ -117,6 +118,7 @@ func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool { | |||
| 117 | 118 | Name: info.Name, | |
| 118 | 119 | Description: info.Description, | |
| 119 | 120 | Schema: info.Schema, | |
| 121 | + | Deferred: info.Deferred, | |
| 120 | 122 | }) | |
| 121 | 123 | } | |
| 122 | 124 | ||
| @@ -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 out | 112 | 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 | |||
| 407 | 407 | if err != nil { | |
| 408 | 408 | return "", "", "", err | |
| 409 | 409 | } | |
| 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 | + | } | |
| 412 | 418 | } | |
| 413 | 419 | switch provider { | |
| 414 | 420 | case "anthropic": | |
| @@ -554,6 +560,9 @@ func loadOAuthToken(providerName string) *auth.OAuthToken { | |||
| 554 | 560 | return c.GithubCopilot.OAuth | |
| 555 | 561 | } | |
| 556 | 562 | } | |
| 563 | + | if pc, ok := c.AdditionalAPIKeyCreds[providerName]; ok { | |
| 564 | + | return pc.OAuth | |
| 565 | + | } | |
| 557 | 566 | return nil | |
| 558 | 567 | } | |
| 559 | 568 | ||
| @@ -575,6 +584,22 @@ func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthTok | |||
| 575 | 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 | 603 | var op auth.OAuthProvider | |
| 579 | 604 | switch providerName { | |
| 580 | 605 | 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 "", "", "", err | 408 | 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", "", nil | 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 | + | } | |||
| 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.OAuth | 560 | return c.GithubCopilot.OAuth | ||
| 555 | } | 561 | } | ||
| 556 | } | 562 | } | ||
| 563 | + | if pc, ok := c.AdditionalAPIKeyCreds[providerName]; ok { | |||
| 564 | + | return pc.OAuth | |||
| 565 | + | } | |||
| 557 | return nil | 566 | 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.OAuthProvider | 603 | 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 | |||
| 151 | 151 | // one with TextResult, ImageResult, or directly when you need to | |
| 152 | 152 | // combine multiple blocks. | |
| 153 | 153 | type ToolResult struct { | |
| 154 | - | Content []ToolContent | |
| 155 | - | IsError bool | |
| 154 | + | Content []ToolContent | |
| 155 | + | IsError bool | |
| 156 | + | ActivateTools []string | |
| 156 | 157 | } | |
| 157 | 158 | ||
| 158 | 159 | // ToolContent is one block of tool output. Either Text is set, or | |
| @@ -279,6 +280,7 @@ type toolDef struct { | |||
| 279 | 280 | name string | |
| 280 | 281 | description string | |
| 281 | 282 | schema json.RawMessage | |
| 283 | + | deferred bool | |
| 282 | 284 | } | |
| 283 | 285 | ||
| 284 | 286 | // 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 | 390 | // Naming conflicts with built-in tools (read, write, edit, bash, | |
| 389 | 391 | // skill) are silently shadowed by the built-in. | |
| 390 | 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 | 403 | e.mu.Lock() | |
| 392 | 404 | 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 | 406 | e.mu.Unlock() | |
| 395 | 407 | } | |
| 396 | 408 | ||
| @@ -532,6 +544,7 @@ func (e *Extension) Run() error { | |||
| 532 | 544 | Name: td.name, | |
| 533 | 545 | Description: td.description, | |
| 534 | 546 | Schema: td.schema, | |
| 547 | + | Deferred: td.deferred, | |
| 535 | 548 | }) | |
| 536 | 549 | } | |
| 537 | 550 | var intercepts []string | |
| @@ -704,10 +717,11 @@ func (e *Extension) respondTool(id string, r ToolResult) { | |||
| 704 | 717 | }) | |
| 705 | 718 | } | |
| 706 | 719 | _ = 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, | |
| 711 | 725 | }) | |
| 712 | 726 | } | |
| 713 | 727 | ||
| @@ -151,8 +151,9 @@ type AssistantMessageHandler func(text string) AssistantMessageDecision | |||||
| 151 | // one with TextResult, ImageResult, or directly when you need to | 151 | // 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 []ToolContent | 154 | + | Content []ToolContent |
| 155 | - | IsError bool | 155 | + | IsError bool |
| 156 | + | ActivateTools []string | |||
| 156 | } | 157 | } | ||
| 157 | 158 | ||||
| 158 | // ToolContent is one block of tool output. Either Text is set, or | 159 | // ToolContent is one block of tool output. Either Text is set, or | ||
| @@ -279,6 +280,7 @@ type toolDef struct { | |||||
| 279 | name string | 280 | name string | ||
| 280 | description string | 281 | description string | ||
| 281 | schema json.RawMessage | 282 | schema json.RawMessage | ||
| 283 | + | deferred bool | |||
| 282 | } | 284 | } | ||
| 283 | 285 | ||||
| 284 | // HostInfo is what the host (zot) tells us in HelloAck. Useful for | 286 | // 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] = fn | 404 | 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 []string | 550 | 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) { | |||
| 220 | 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, ®istration); 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 | 272 | // TestBlockingToolWaitsForPanelKey is the core integration test for | |
| 224 | 273 | // the human-in-the-loop pattern: the tool handler opens a panel, | |
| 225 | 274 | // 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, ®istration); 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 for | 272 | // 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 key | 274 | // 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 { | |||
| 966 | 966 | Name string | |
| 967 | 967 | Description string | |
| 968 | 968 | Schema json.RawMessage | |
| 969 | + | Deferred bool | |
| 969 | 970 | } | |
| 970 | 971 | ||
| 971 | 972 | // Tools returns a snapshot of every (extension, tool) pair currently | |
| @@ -982,6 +983,7 @@ func (m *Manager) Tools() []ToolInfo { | |||
| 982 | 983 | Name: t.Name, | |
| 983 | 984 | Description: t.Description, | |
| 984 | 985 | Schema: t.Schema, | |
| 986 | + | Deferred: t.Deferred, | |
| 985 | 987 | }) | |
| 986 | 988 | } | |
| 987 | 989 | } | |
| @@ -966,6 +966,7 @@ type ToolInfo struct { | |||||
| 966 | Name string | 966 | Name string | ||
| 967 | Description string | 967 | Description string | ||
| 968 | Schema json.RawMessage | 968 | Schema json.RawMessage | ||
| 969 | + | Deferred bool | |||
| 969 | } | 970 | } | ||
| 970 | 971 | ||||
| 971 | // Tools returns a snapshot of every (extension, tool) pair currently | 972 | // 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 { | |||
| 25 | 25 | extension string | |
| 26 | 26 | manager *Manager | |
| 27 | 27 | timeout time.Duration | |
| 28 | + | deferred bool | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | 31 | // NewTool returns a core.Tool that round-trips invocations through | |
| @@ -38,6 +39,7 @@ func NewTool(mgr *Manager, info ToolInfo) core.Tool { | |||
| 38 | 39 | extension: info.Extension, | |
| 39 | 40 | manager: mgr, | |
| 40 | 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 | 47 | func (t *extensionTool) Description() string { return t.description } | |
| 46 | 48 | func (t *extensionTool) Schema() json.RawMessage { return t.schema } | |
| 47 | 49 | func (t *extensionTool) Extension() string { return t.extension } | |
| 50 | + | func (t *extensionTool) Deferred() bool { return t.deferred } | |
| 48 | 51 | ||
| 49 | 52 | // Execute is what the agent calls when the LLM invokes the tool. It | |
| 50 | 53 | // 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 | 63 | Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("extension %s/%s failed: %v", t.extension, t.name, err)}}, | |
| 61 | 64 | }, nil | |
| 62 | 65 | } | |
| 63 | - | out := core.ToolResult{IsError: resp.IsError} | |
| 66 | + | out := core.ToolResult{IsError: resp.IsError, ActivateTools: resp.ActivateTools} | |
| 64 | 67 | for _, b := range resp.Content { | |
| 65 | 68 | switch b.Type { | |
| 66 | 69 | case "text": | |
| @@ -25,6 +25,7 @@ type extensionTool struct { | |||||
| 25 | extension string | 25 | extension string | ||
| 26 | manager *Manager | 26 | manager *Manager | ||
| 27 | timeout time.Duration | 27 | timeout time.Duration | ||
| 28 | + | deferred bool | |||
| 28 | } | 29 | } | ||
| 29 | 30 | ||||
| 30 | // NewTool returns a core.Tool that round-trips invocations through | 31 | // 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. It | 52 | // 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 the | 53 | // 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 | }, nil | 64 | }, 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 { | |||
| 44 | 44 | Name string `json:"name"` | |
| 45 | 45 | Description string `json:"description,omitempty"` | |
| 46 | 46 | Schema json.RawMessage `json:"schema"` | |
| 47 | + | Deferred bool `json:"deferred,omitempty"` | |
| 47 | 48 | } | |
| 48 | 49 | ||
| 49 | 50 | type ReadyFromExt struct { | |
| @@ -66,10 +67,11 @@ type EventInterceptResponseFromExt struct { | |||
| 66 | 67 | } | |
| 67 | 68 | ||
| 68 | 69 | 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"` | |
| 73 | 75 | } | |
| 74 | 76 | ||
| 75 | 77 | 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) { | |||
| 4275 | 4275 | if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil { | |
| 4276 | 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 | 4289 | // Always run the manual/copy-code flow in parallel with the local | |
| 4279 | 4290 | // callback server so headless environments (docker, SSH) can paste | |
| 4280 | 4291 | // 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 local | 4289 | // Always run the manual/copy-code flow in parallel with the local | ||
| 4279 | // callback server so headless environments (docker, SSH) can paste | 4290 | // 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 { | |||
| 149 | 149 | for i := start; i < end; i++ { | |
| 150 | 150 | o := opts[i] | |
| 151 | 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 | 157 | plain := label + tag | |
| 154 | 158 | if i == d.cursor { | |
| 155 | 159 | lines = append(lines, th.PadHighlight(plain, width)) | |
| @@ -172,6 +176,11 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 172 | 176 | lines = append(lines, th.FG256(th.Accent, seg)) | |
| 173 | 177 | } | |
| 174 | 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 | 184 | lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):")) | |
| 176 | 185 | if d.codeEd == nil { | |
| 177 | 186 | d.codeEd = tui.NewEditor(th.AccentBar(th.Accent)) | |
| @@ -241,7 +250,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 241 | 250 | func providersForMethod(method string) []string { | |
| 242 | 251 | var providers []string | |
| 243 | 252 | if method == "oauth" { | |
| 244 | - | providers = []string{"anthropic", "openai-codex", "kimi", "github-copilot"} | |
| 253 | + | providers = []string{"anthropic", "openai-codex", "kimi", "xai", "github-copilot"} | |
| 245 | 254 | } else { | |
| 246 | 255 | providers = auth.APIKeyProviders() | |
| 247 | 256 | // 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 + tag | 157 | 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 []string | 251 | 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 appear | 256 | // 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 | |||
| 629 | 629 | // a single tool-role message carrying all results. | |
| 630 | 630 | func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) { | |
| 631 | 631 | var results []provider.Content | |
| 632 | + | var addedTools []string | |
| 632 | 633 | hadError := false | |
| 633 | 634 | ||
| 634 | 635 | for _, c := range msg.Content { | |
| @@ -645,13 +646,19 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun | |||
| 645 | 646 | Content: res.Content, | |
| 646 | 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 | 654 | sink(EvToolResult{ID: tc.ID, Result: res}) | |
| 649 | 655 | } | |
| 650 | 656 | ||
| 651 | 657 | 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, | |
| 655 | 662 | }, hadError | |
| 656 | 663 | } | |
| 657 | 664 | ||
| @@ -757,6 +764,15 @@ func mirrorToolImagesAsUser(msg provider.Message) provider.Message { | |||
| 757 | 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 | 776 | func extractText(msg provider.Message) string { | |
| 761 | 777 | var out string | |
| 762 | 778 | 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.Content | 631 | var results []provider.Content | ||
| 632 | + | var addedTools []string | |||
| 632 | hadError := false | 633 | 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 | }, hadError | 662 | }, 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 string | 777 | 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 | |||
| 89 | 89 | ||
| 90 | 90 | // Replace transcript: one synthetic user message with the summary, | |
| 91 | 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 | 100 | synthetic := provider.Message{ | |
| 93 | - | Role: provider.RoleUser, | |
| 101 | + | Role: provider.RoleUser, | |
| 102 | + | AddedToolNames: activatedTools, | |
| 94 | 103 | Content: []provider.Content{ | |
| 95 | 104 | provider.TextBlock{Text: "## Context Summary (compacted)\n\n" + summary}, | |
| 96 | 105 | }, | |
| @@ -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) { | |||
| 809 | 809 | ||
| 810 | 810 | func hydrateMessageObject(rawMessage []byte) (provider.Message, error) { | |
| 811 | 811 | 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"` | |
| 816 | 817 | } | |
| 817 | 818 | if err := json.Unmarshal(rawMessage, &row); err != nil { | |
| 818 | 819 | 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 | 822 | for _, raw := range row.Content { | |
| 822 | 823 | var head struct { | |
| 823 | 824 | 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{}, err | 819 | 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 { | |||
| 31 | 31 | Content []provider.Content | |
| 32 | 32 | // IsError marks this result as an error to the LLM. | |
| 33 | 33 | 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 | 37 | // Details is arbitrary data for UIs and logs; not sent to the LLM. | |
| 35 | 38 | Details any | |
| 36 | 39 | } | |
| @@ -62,10 +65,15 @@ func (r Registry) Specs() []provider.Tool { | |||
| 62 | 65 | out := make([]provider.Tool, 0, len(r)) | |
| 63 | 66 | for _, name := range names { | |
| 64 | 67 | t := r[name] | |
| 68 | + | deferred := false | |
| 69 | + | if d, ok := t.(interface{ Deferred() bool }); ok { | |
| 70 | + | deferred = d.Deferred() | |
| 71 | + | } | |
| 65 | 72 | out = append(out, provider.Tool{ | |
| 66 | 73 | Name: t.Name(), | |
| 67 | 74 | Description: t.Description(), | |
| 68 | 75 | Schema: t.Schema(), | |
| 76 | + | Deferred: deferred, | |
| 69 | 77 | }) | |
| 70 | 78 | } | |
| 71 | 79 | return out | |
| @@ -31,6 +31,9 @@ type ToolResult struct { | |||||
| 31 | Content []provider.Content | 31 | 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 bool | 33 | 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 any | 38 | 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 out | 79 | return out | ||
M packages/provider/auth/manager.go +43 -2
| @@ -136,6 +136,9 @@ func (m *Manager) StartOAuth(provider string) (string, error) { | |||
| 136 | 136 | if provider == "kimi" { | |
| 137 | 137 | return m.StartKimiDeviceOAuth() | |
| 138 | 138 | } | |
| 139 | + | if provider == "xai" { | |
| 140 | + | return m.StartXAIDeviceOAuth() | |
| 141 | + | } | |
| 139 | 142 | if provider == "github-copilot" { | |
| 140 | 143 | return m.StartGitHubCopilotDeviceOAuth() | |
| 141 | 144 | } | |
| @@ -152,7 +155,7 @@ func (m *Manager) StartOAuth(provider string) (string, error) { | |||
| 152 | 155 | case "deepseek": | |
| 153 | 156 | return "", fmt.Errorf("deepseek login is api-key only; use api key login") | |
| 154 | 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 | 161 | m.mu.Lock() | |
| @@ -258,6 +261,41 @@ func (m *Manager) StartKimiDeviceOAuth() (string, error) { | |||
| 258 | 261 | 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 | 299 | // StartGitHubCopilotDeviceOAuth starts GitHub Copilot's device-code subscription login. | |
| 262 | 300 | func (m *Manager) StartGitHubCopilotDeviceOAuth() (string, error) { | |
| 263 | 301 | ctx, cancel := context.WithCancel(context.Background()) | |
| @@ -304,6 +342,9 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||
| 304 | 342 | if provider == "kimi" { | |
| 305 | 343 | return m.StartKimiDeviceOAuth() | |
| 306 | 344 | } | |
| 345 | + | if provider == "xai" { | |
| 346 | + | return m.StartXAIDeviceOAuth() | |
| 347 | + | } | |
| 307 | 348 | if provider == "github-copilot" { | |
| 308 | 349 | return m.StartGitHubCopilotDeviceOAuth() | |
| 309 | 350 | } | |
| @@ -320,7 +361,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||
| 320 | 361 | case "deepseek": | |
| 321 | 362 | return "", fmt.Errorf("deepseek login is api-key only; use api key login") | |
| 322 | 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 | 367 | 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, nil | 261 | 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. | |
| 3 | 3 | // | |
| 4 | 4 | // All credentials live in $ZOT_HOME/auth.json (mode 0600). | |
| 5 | 5 | package auth | |
| @@ -1,5 +1,5 @@ | |||||
| 1 | - | // Package auth handles credential storage and the two login flows | 1 | + | // 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 auth | 5 | 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{ | |||
| 18 | 18 | {Provider: "kimi", ID: "kimi-k2-thinking", DisplayName: "Kimi K2 Thinking", | |
| 19 | 19 | ContextWindow: 262144, MaxOutput: 32000, Reasoning: true, | |
| 20 | 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 | 50 | // ----- openai-responses (public OpenAI Responses API) ----- | |
| 23 | 51 | {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 { | |||
| 144 | 144 | Name string `json:"name,omitempty"` | |
| 145 | 145 | ToolCalls []oaiToolCall `json:"tool_calls,omitempty"` | |
| 146 | 146 | ToolCallID string `json:"tool_call_id,omitempty"` | |
| 147 | + | Tools []oaiTool `json:"tools,omitempty"` | |
| 147 | 148 | // ReasoningContent carries the model's chain-of-thought summary | |
| 148 | 149 | // alongside an assistant tool-call message. Required by Kimi's | |
| 149 | 150 | // chat completions endpoint when thinking is enabled and the | |
| @@ -267,6 +268,18 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 267 | 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 | 283 | // DeepSeek's chat-completions API rejects the multimodal content | |
| 271 | 284 | // schema (parts arrays containing image_url). Force every user/tool | |
| 272 | 285 | // 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 | 374 | 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)) | |
| 357 | 379 | } | |
| 358 | 380 | if len(out.Tools) > 0 { | |
| 359 | 381 | out.ToolChoice = "auto" | |
| @@ -362,6 +384,20 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 362 | 384 | 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 | 401 | func buildOAIUserContent(blocks []Content, textOnly bool) interface{} { | |
| 366 | 402 | hasImage := false | |
| 367 | 403 | 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 summary | 148 | // ReasoningContent carries the model's chain-of-thought summary | ||
| 148 | // alongside an assistant tool-call message. Required by Kimi's | 149 | // alongside an assistant tool-call message. Required by Kimi's | ||
| 149 | // chat completions endpoint when thinking is enabled and the | 150 | // 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 content | 283 | // DeepSeek's chat-completions API rejects the multimodal content | ||
| 271 | // schema (parts arrays containing image_url). Force every user/tool | 284 | // schema (parts arrays containing image_url). Force every user/tool | ||
| 272 | // message to a plain string and silently drop image blocks for | 285 | // 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 oaiTool | 375 | + | if t.Deferred && (deferredMode || !activatedTools[t.Name]) { |
| 352 | - | tool.Type = "function" | 376 | + | continue |
| 353 | - | tool.Function.Name = t.Name | 377 | + | } |
| 354 | - | tool.Function.Description = t.Description | 378 | + | 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, nil | 384 | 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 := false | 402 | 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 { | |||
| 114 | 114 | ||
| 115 | 115 | // Message is a single turn in the conversation. | |
| 116 | 116 | 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"` | |
| 121 | 122 | } | |
| 122 | 123 | ||
| 123 | 124 | // Tool is a tool definition advertised to the LLM. | |
| @@ -125,6 +126,8 @@ type Tool struct { | |||
| 125 | 126 | Name string `json:"name"` | |
| 126 | 127 | Description string `json:"description"` | |
| 127 | 128 | 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 | 133 | // 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 arguments | 128 | 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. | ||