lcode-cli 0.21.0__tar.gz → 0.22.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/CHANGELOG.md +13 -1
  2. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/PKG-INFO +3 -2
  3. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/README.md +2 -1
  4. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/mcp.md +31 -0
  5. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/quickstart.md +1 -1
  6. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/pyproject.toml +2 -0
  7. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/__init__.py +1 -1
  8. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/catalog.toml +21 -0
  9. lcode_cli-0.22.0/src/lcode/servers/xai.py +289 -0
  10. lcode_cli-0.22.0/tests/test_xai.py +163 -0
  11. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.editorconfig +0 -0
  12. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.github/CODEOWNERS +0 -0
  13. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  14. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  15. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  16. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  17. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  18. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.github/dependabot.yml +0 -0
  19. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.github/workflows/ci.yml +0 -0
  20. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.github/workflows/docs.yml +0 -0
  21. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.github/workflows/release.yml +0 -0
  22. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.gitignore +0 -0
  23. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.pre-commit-config.yaml +0 -0
  24. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/AGENTS.md +0 -0
  25. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/CODE_OF_CONDUCT.md +0 -0
  26. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/CONTRIBUTING.md +0 -0
  27. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/LICENSE +0 -0
  28. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/SECURITY.md +0 -0
  29. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/action.yml +0 -0
  30. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/agents.md +0 -0
  31. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/assets/demo.svg +0 -0
  32. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/assets/extra.css +0 -0
  33. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/assets/logo.svg +0 -0
  34. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/assets/social-preview.png +0 -0
  35. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/automation.md +0 -0
  36. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/bench.md +0 -0
  37. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/changelog.md +0 -0
  38. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/commands.md +0 -0
  39. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/configuration.md +0 -0
  40. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/contributing.md +0 -0
  41. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/editors.md +0 -0
  42. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/git.md +0 -0
  43. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/hooks.md +0 -0
  44. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/how-it-works.md +0 -0
  45. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/index.md +0 -0
  46. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/installation.md +0 -0
  47. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/lsp.md +0 -0
  48. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/memory.md +0 -0
  49. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/models.md +0 -0
  50. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/sandbox.md +0 -0
  51. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/search.md +0 -0
  52. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/servers.md +0 -0
  53. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/troubleshooting.md +0 -0
  54. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/usage.md +0 -0
  55. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/examples/README.md +0 -0
  56. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/examples/api/fix_tests.py +0 -0
  57. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/examples/commands/review.md +0 -0
  58. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/examples/skills/commit-message/SKILL.md +0 -0
  59. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
  60. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/install.sh +0 -0
  61. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/mkdocs.yml +0 -0
  62. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/overrides/main.html +0 -0
  63. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/scripts/record_demo.py +0 -0
  64. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/scripts/social-preview.html +0 -0
  65. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/__main__.py +0 -0
  66. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/acp.py +0 -0
  67. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/action.py +0 -0
  68. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/agent.py +0 -0
  69. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/api.py +0 -0
  70. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/backends.py +0 -0
  71. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/bench.py +0 -0
  72. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/bench_backend.py +0 -0
  73. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/catalog.py +0 -0
  74. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/checkpoints.py +0 -0
  75. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/cli.py +0 -0
  76. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/codesearch.py +0 -0
  77. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/config.py +0 -0
  78. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/context.py +0 -0
  79. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/extensions.py +0 -0
  80. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/frontmatter.py +0 -0
  81. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/gitflow.py +0 -0
  82. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/hardware.py +0 -0
  83. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/hooks.py +0 -0
  84. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/jobs.py +0 -0
  85. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/limits.py +0 -0
  86. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/lsp.py +0 -0
  87. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/__init__.py +0 -0
  88. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/auth.py +0 -0
  89. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/catalog.py +0 -0
  90. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/client.py +0 -0
  91. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/commands.py +0 -0
  92. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/config.py +0 -0
  93. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/http.py +0 -0
  94. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/manager.py +0 -0
  95. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/protocol.py +0 -0
  96. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/server.py +0 -0
  97. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/stdio.py +0 -0
  98. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/memory.py +0 -0
  99. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/models.toml +0 -0
  100. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/notify.py +0 -0
  101. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/ollama.py +0 -0
  102. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/permissions.py +0 -0
  103. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/planning.py +0 -0
  104. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/render.py +0 -0
  105. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/repl.py +0 -0
  106. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/repomap.py +0 -0
  107. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/sandbox.py +0 -0
  108. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/servers/__init__.py +0 -0
  109. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/servers/encord.py +0 -0
  110. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/servers/valohai.py +0 -0
  111. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/sessions.py +0 -0
  112. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/subagents.py +0 -0
  113. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/tools.py +0 -0
  114. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/update.py +0 -0
  115. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/vision.py +0 -0
  116. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/web.py +0 -0
  117. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/conftest.py +0 -0
  118. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/fake_lsp_server.py +0 -0
  119. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/fake_mcp_server.py +0 -0
  120. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_acp.py +0 -0
  121. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_action.py +0 -0
  122. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_agent.py +0 -0
  123. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_api.py +0 -0
  124. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_backends.py +0 -0
  125. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_bench.py +0 -0
  126. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_catalog.py +0 -0
  127. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_checkpoints.py +0 -0
  128. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_cli.py +0 -0
  129. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_codesearch.py +0 -0
  130. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_config.py +0 -0
  131. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_context.py +0 -0
  132. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_context_picker.py +0 -0
  133. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_extensions.py +0 -0
  134. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_gitflow.py +0 -0
  135. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_hooks.py +0 -0
  136. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_jobs.py +0 -0
  137. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_lsp.py +0 -0
  138. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_mcp.py +0 -0
  139. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_memory.py +0 -0
  140. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_permissions.py +0 -0
  141. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_plan_mode.py +0 -0
  142. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_repomap.py +0 -0
  143. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_sandbox.py +0 -0
  144. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_servers.py +0 -0
  145. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_sessions.py +0 -0
  146. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_subagents.py +0 -0
  147. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_tools.py +0 -0
  148. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_update.py +0 -0
  149. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_vision.py +0 -0
  150. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_web.py +0 -0
  151. {lcode_cli-0.21.0 → lcode_cli-0.22.0}/uv.lock +0 -0
@@ -6,6 +6,17 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.22.0] - 2026-10-06
10
+
11
+ ### Added
12
+
13
+ - `lcode mcp add xai`: images, image edits, videos and speech with xAI's Grok Imagine and text to
14
+ speech APIs, in the cloud and billed to your xAI account. It asks for your xAI API key, and runs
15
+ `lcode-mcp-xai`, a new MCP server that ships with lcode, with the tools `generate_image`,
16
+ `edit_image`, `generate_video` (waits for the video and downloads it), `video_status` and
17
+ `text_to_speech`. Files are saved in `generated/` in the project or where you ask. See
18
+ [Images, videos and speech with xAI](https://nasser1941.github.io/lcode/mcp/#images-videos-and-speech-with-xai).
19
+
9
20
  ## [0.21.0] - 2026-10-06
10
21
 
11
22
  ### Added
@@ -480,7 +491,8 @@ First public release.
480
491
  - `AGENTS.md` project instructions and `/init` to generate them.
481
492
  - One-line installer for Linux and macOS.
482
493
 
483
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.21.0...HEAD
494
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.22.0...HEAD
495
+ [0.22.0]: https://github.com/nasser1941/lcode/compare/v0.21.0...v0.22.0
484
496
  [0.21.0]: https://github.com/nasser1941/lcode/compare/v0.20.0...v0.21.0
485
497
  [0.20.0]: https://github.com/nasser1941/lcode/compare/v0.19.0...v0.20.0
486
498
  [0.19.0]: https://github.com/nasser1941/lcode/compare/v0.18.0...v0.19.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.21.0
3
+ Version: 0.22.0
4
4
  Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
5
5
  Project-URL: Homepage, https://nasser1941.github.io/lcode/
6
6
  Project-URL: Documentation, https://nasser1941.github.io/lcode/
@@ -116,7 +116,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
116
116
  (`lcode mcp add atlassian`), image generation with ComfyUI, or any other MCP server. Browser sign-in (OAuth) is built in.
117
117
  - **Sees images.** Attach a screenshot or mockup with `@path` and lcode looks at it, using the
118
118
  vision part of your model. With ComfyUI (`lcode mcp add comfyui`) it can also generate and edit
119
- images with local models such as FLUX, SDXL and Qwen-Image.
119
+ images with local models such as FLUX, SDXL and Qwen-Image, or, in the cloud, images, videos
120
+ and speech with xAI's Grok Imagine (`lcode mcp add xai`).
120
121
  - **Web search when needed.** Looks up the latest versions, docs and error messages with Ollama web
121
122
  search, Brave, Tavily or your own SearXNG, and reads pages as clean text.
122
123
  - **Private.** The model runs on your machine, lcode never uploads your files and has no telemetry.
@@ -80,7 +80,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
80
80
  (`lcode mcp add atlassian`), image generation with ComfyUI, or any other MCP server. Browser sign-in (OAuth) is built in.
81
81
  - **Sees images.** Attach a screenshot or mockup with `@path` and lcode looks at it, using the
82
82
  vision part of your model. With ComfyUI (`lcode mcp add comfyui`) it can also generate and edit
83
- images with local models such as FLUX, SDXL and Qwen-Image.
83
+ images with local models such as FLUX, SDXL and Qwen-Image, or, in the cloud, images, videos
84
+ and speech with xAI's Grok Imagine (`lcode mcp add xai`).
84
85
  - **Web search when needed.** Looks up the latest versions, docs and error messages with Ollama web
85
86
  search, Brave, Tavily or your own SearXNG, and reads pages as clean text.
86
87
  - **Private.** The model runs on your machine, lcode never uploads your files and has no telemetry.
@@ -29,6 +29,7 @@ and find the code for the top one."*
29
29
  | `github` | Repositories, issues, pull requests, Actions | a GitHub token (or the GitHub CLI) |
30
30
  | `playwright` | A real (headless) browser: open pages, click, fill forms | `npx` |
31
31
  | `comfyui` | Generate and edit images with local models (FLUX, SDXL, SD 1.5, Qwen-Image) | `uvx` and [ComfyUI](#images-with-comfyui) running locally |
32
+ | `xai` | Generate and edit images, and generate videos and speech, with xAI's Grok Imagine (paid, in the cloud) | `uvx`, an xAI API key ([below](#images-videos-and-speech-with-xai)) |
32
33
  | `context7` | Up-to-date docs and examples for thousands of libraries | nothing (an API key is optional) |
33
34
  | `sentry` | Errors, issues, traces and releases | a Sentry account (browser sign-in) |
34
35
  | `postgres` | Schemas, read-only queries, query performance | `uvx`, a connection URL |
@@ -137,6 +138,36 @@ size, prompt), which can take several minutes of trial and error. Once an image
137
138
  lcode to save that workflow in your project (for example `assets/icon.workflow.json`) and reuse it:
138
139
  later images are a single `run_workflow` call.
139
140
 
141
+ ### Images, videos and speech with xAI
142
+
143
+ xAI's [Grok Imagine](https://docs.x.ai/developers/model-capabilities/imagine) and
144
+ [text to speech](https://docs.x.ai/developers/models/text-to-speech) APIs make images, videos and
145
+ speech in the cloud: no GPU needed, but each call is billed to your xAI account. lcode ships an MCP
146
+ server for them, `lcode-mcp-xai`.
147
+
148
+ ```bash
149
+ lcode mcp add xai # asks for your xAI API key (create one at https://console.x.ai)
150
+ ```
151
+
152
+ The key is typed hidden and kept in `~/.config/lcode/mcp.json`, readable only by you. If
153
+ `XAI_API_KEY` is already set in your environment, lcode uses that and doesn't store the key.
154
+
155
+ | Tool | | Costs |
156
+ |---|---|---|
157
+ | `generate_image` | Images from a description: 1–10 at a time, aspect ratio, 1k or 2k | $0.02–0.05 an image |
158
+ | `edit_image` | Change a local image as described | the same |
159
+ | `generate_video` | A 1–15 second MP4 from a description, optionally starting from a local image, up to 1080p. Waits for it (a minute or more) | $0.02–0.08 a second |
160
+ | `video_status` | Fetch a video that wasn't ready within 10 minutes | |
161
+ | `text_to_speech` | Speech in one of 26 voices and 20 languages, as MP3 or WAV | per character |
162
+
163
+ Files are saved in `generated/` in the project, or where you ask (*"generate an app icon and save
164
+ it as assets/icon.png"*). xAI has no music generation API.
165
+
166
+ Your prompts, and the images you edit or animate, go to xAI. lcode asks before each call, since each
167
+ one costs money. To stop asking for some tools, list them under `"allow"` for the server in
168
+ `mcp.json`, for example `"allow": ["generate_image", "text_to_speech"]`. xAI applies its own usage
169
+ policies to what it generates; when it refuses a prompt, lcode passes its message to the model.
170
+
140
171
  ### Metabase
141
172
 
142
173
  Metabase has its own MCP server inside every instance (Metabase 60 and later), at
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.21.0 — local coding agent │
15
+ │ lcode v0.22.0 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -49,6 +49,7 @@ Changelog = "https://github.com/nasser1941/lcode/blob/main/CHANGELOG.md"
49
49
  lcode = "lcode.cli:main"
50
50
  lcode-mcp-valohai = "lcode.servers.valohai:main"
51
51
  lcode-mcp-encord = "lcode.servers.encord:main"
52
+ lcode-mcp-xai = "lcode.servers.xai:main"
52
53
 
53
54
  [dependency-groups]
54
55
  dev = ["pytest>=8", "ruff>=0.6"]
@@ -88,6 +89,7 @@ ignore = [
88
89
  "src/lcode/action.py" = ["E501"]
89
90
  "src/lcode/acp.py" = ["E501"]
90
91
  "src/lcode/update.py" = ["E501"]
92
+ "src/lcode/servers/xai.py" = ["E501"]
91
93
 
92
94
  [tool.pytest.ini_options]
93
95
  testpaths = ["tests"]
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.21.0"
6
+ __version__ = "0.22.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
@@ -281,3 +281,24 @@ secret = true
281
281
  var = "VALOHAI_HOST"
282
282
  prompt = "Valohai address (Enter for app.valohai.com; for a self-hosted installation, its URL)"
283
283
  optional = true
284
+
285
+ [xai]
286
+ name = "xAI Grok Imagine"
287
+ description = "Generate and edit images, and generate videos and speech, with xAI's Grok Imagine and voice API (paid, in the cloud)"
288
+ homepage = "https://nasser1941.github.io/lcode/mcp/#xai"
289
+ requires = ["uvx"]
290
+ setup = """Uses xAI's API: your prompts, and images you edit or animate, go to xAI, and every call is billed to your xAI
291
+ account (an image $0.02-0.05, video $0.02-0.08 a second, speech per character). Create an API key at https://console.x.ai.
292
+ Files are saved in generated/ in the project. lcode asks before each call; to stop asking for some tools, list them
293
+ under "allow" for this server in mcp.json. xAI has no music generation."""
294
+
295
+ [xai.server]
296
+ command = "uvx"
297
+ args = ["--isolated", "--python", "3.12", "--from", "lcode-cli", "lcode-mcp-xai"]
298
+ env = { XAI_API_KEY = "${XAI_API_KEY}" }
299
+ timeout = 900 # a video takes minutes
300
+
301
+ [[xai.inputs]]
302
+ var = "XAI_API_KEY"
303
+ prompt = "xAI API key (create one at https://console.x.ai)"
304
+ secret = true
@@ -0,0 +1,289 @@
1
+ """An MCP server for xAI's Grok Imagine and voice APIs: images, image edits, videos and speech.
2
+
3
+ xAI has an HTTP API but no MCP server, so lcode ships this one. It needs an API key (XAI_API_KEY,
4
+ from https://console.x.ai) and saves what it makes in the project, in `generated/` unless a path is
5
+ given. The prompts, and any image it edits or animates, go to xAI, which bills per image, per
6
+ second of video and per character of speech, and applies its own usage policies.
7
+
8
+ Run: lcode-mcp-xai (lcode mcp add xai sets it up)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import base64
15
+ import mimetypes
16
+ import os
17
+ import re
18
+ import time
19
+ from pathlib import Path
20
+
21
+ import requests
22
+
23
+ from lcode import __version__
24
+ from lcode.mcp.server import Server, ToolFailure
25
+
26
+ API = "https://api.x.ai/v1"
27
+ TIMEOUT = 120
28
+ IMAGE_MODELS = ["grok-imagine-image-2.0", "grok-imagine-image-quality", "grok-imagine-image"]
29
+ VIDEO_MODELS = ["grok-imagine-video-1.5", "grok-imagine-video-1.5-lite", "grok-imagine-video"]
30
+ ASPECT_RATIOS = ["auto", "1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "2:1", "1:2", "21:9"]
31
+ VOICES = ["eve", "ara", "leo", "rex", "sal", "luna", "iris", "orion", "helix", "zagan", "carina", "altair", "zenith",
32
+ "perseus", "helios", "lux", "kepler", "rigel", "cosmo", "celeste", "ursa", "sirius", "lumen", "castor",
33
+ "naksh", "atlas"] # fmt: skip
34
+ AUDIO_FORMATS = {"mp3": ".mp3", "wav": ".wav"}
35
+ VIDEO_WAIT = 600 # seconds generate_video waits before handing back the request id
36
+ POLL = 5
37
+
38
+
39
+ class XAI:
40
+ def __init__(self, key: str, base: str = API, output: Path | None = None):
41
+ self.base = base.rstrip("/")
42
+ self.output = output or Path("generated")
43
+ self.session = requests.Session()
44
+ self.session.headers.update({"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
45
+
46
+ def request(self, method: str, path: str, **kwargs) -> requests.Response:
47
+ try:
48
+ r = self.session.request(method, f"{self.base}{path}", timeout=TIMEOUT, **kwargs)
49
+ except requests.RequestException as e:
50
+ raise ToolFailure(f"can't reach xAI: {e}") from e
51
+ if r.status_code in (401, 403):
52
+ raise ToolFailure(
53
+ f"xAI refused the request ({r.status_code}): {_error(r)}. Check XAI_API_KEY (console.x.ai)."
54
+ )
55
+ if r.status_code == 429:
56
+ raise ToolFailure(f"xAI's rate limit or credit limit was reached: {_error(r)}")
57
+ if r.status_code >= 400:
58
+ raise ToolFailure(f"xAI error {r.status_code}: {_error(r)}")
59
+ return r
60
+
61
+ def post(self, path: str, body: dict) -> dict:
62
+ return self.request("POST", path, json=body).json()
63
+
64
+ def download(self, url: str) -> bytes:
65
+ try:
66
+ r = requests.get(url, timeout=TIMEOUT)
67
+ r.raise_for_status()
68
+ except requests.RequestException as e:
69
+ raise ToolFailure(f"couldn't download the result from xAI: {e}") from e
70
+ return r.content
71
+
72
+ def target(self, path: str, stem: str, suffix: str) -> Path:
73
+ """Where to save: the given path, or a new file in the output folder."""
74
+ if path:
75
+ target = Path(path).expanduser()
76
+ if not target.suffix:
77
+ target = target.with_suffix(suffix)
78
+ else:
79
+ name = re.sub(r"[^a-z0-9]+", "-", stem.lower()).strip("-")[:40] or "xai"
80
+ target = self.output / f"{name}-{time.strftime('%Y%m%d-%H%M%S')}{suffix}"
81
+ target.parent.mkdir(parents=True, exist_ok=True)
82
+ n, unique = 2, target
83
+ while unique.exists():
84
+ unique = target.with_name(f"{target.stem}-{n}{target.suffix}")
85
+ n += 1
86
+ return unique
87
+
88
+
89
+ def _error(r: requests.Response) -> str:
90
+ try:
91
+ data = r.json()
92
+ except ValueError:
93
+ return r.text[:300]
94
+ error = data.get("error") if isinstance(data, dict) else None
95
+ if isinstance(error, dict):
96
+ return str(error.get("message") or error)[:300]
97
+ return str(error or data)[:300]
98
+
99
+
100
+ def data_url(path: str) -> str:
101
+ """A local image as a data: URL, for edits and image-to-video."""
102
+ file = Path(path).expanduser()
103
+ if not file.is_file():
104
+ raise ToolFailure(f"{path} doesn't exist")
105
+ mime = mimetypes.guess_type(file.name)[0] or "image/png"
106
+ if mime not in ("image/png", "image/jpeg", "image/webp"):
107
+ raise ToolFailure(f"{path} isn't a PNG, JPEG or WebP image")
108
+ return f"data:{mime};base64,{base64.b64encode(file.read_bytes()).decode()}"
109
+
110
+
111
+ def image_suffix(data: bytes) -> str:
112
+ if data[:3] == b"\xff\xd8\xff":
113
+ return ".jpg"
114
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
115
+ return ".webp"
116
+ return ".png"
117
+
118
+
119
+ def save_images(api: XAI, reply: dict, prompt: str, path: str) -> list[Path]:
120
+ saved = []
121
+ items = reply.get("data") or []
122
+ if not items:
123
+ raise ToolFailure(f"xAI returned no image: {str(reply)[:300]}")
124
+ for i, item in enumerate(items):
125
+ data = base64.b64decode(item["b64_json"]) if item.get("b64_json") else api.download(item["url"])
126
+ many = path and len(items) > 1
127
+ target = api.target(f"{Path(path).with_suffix('')}-{i + 1}" if many else path, prompt, image_suffix(data))
128
+ target.write_bytes(data)
129
+ saved.append(target)
130
+ return saved
131
+
132
+
133
+ def build(api: XAI) -> Server:
134
+ server = Server(
135
+ "xai",
136
+ __version__,
137
+ "xAI's Grok Imagine and voice: generate and edit images, generate videos (from text or a start image) "
138
+ "and speech. Results are saved as files in the project; each call costs money on the user's xAI account. "
139
+ "xAI has no music generation.",
140
+ )
141
+ path_arg = {"type": "string", "description": "Where to save it (default: a new file in generated/)"}
142
+
143
+ @server.tool(
144
+ "generate_image",
145
+ "Generate images from a description with Grok Imagine and save them. Costs $0.02–0.05 per image.",
146
+ {
147
+ "prompt": {"type": "string", "description": "What the image shows, in detail"},
148
+ "n": {"type": "integer", "description": "How many images, 1–10 (default 1)"},
149
+ "aspect_ratio": {"type": "string", "enum": ASPECT_RATIOS, "description": "Default auto"},
150
+ "resolution": {"type": "string", "enum": ["1k", "2k"], "description": "Default 1k"},
151
+ "model": {"type": "string", "enum": IMAGE_MODELS, "description": f"Default {IMAGE_MODELS[0]}"},
152
+ "path": path_arg,
153
+ },
154
+ ["prompt"],
155
+ )
156
+ def generate_image(prompt: str, n: int = 1, aspect_ratio: str = "auto", resolution: str = "1k",
157
+ model: str = IMAGE_MODELS[0], path: str = "") -> str: # fmt: skip
158
+ body = {"model": model, "prompt": prompt, "n": max(1, min(10, int(n))), "response_format": "b64_json"}
159
+ if aspect_ratio and aspect_ratio != "auto":
160
+ body["aspect_ratio"] = aspect_ratio
161
+ if resolution and resolution != "1k":
162
+ body["resolution"] = resolution
163
+ saved = save_images(api, api.post("/images/generations", body), prompt, path)
164
+ return f"Saved {len(saved)} image(s) from {model}:\n" + "\n".join(f"- {p}" for p in saved)
165
+
166
+ @server.tool(
167
+ "edit_image",
168
+ "Change an existing image (a local file) as described, with Grok Imagine, and save the result.",
169
+ {
170
+ "image": {"type": "string", "description": "Path of the image to edit (PNG, JPEG or WebP)"},
171
+ "prompt": {"type": "string", "description": "What to change"},
172
+ "model": {"type": "string", "enum": IMAGE_MODELS, "description": f"Default {IMAGE_MODELS[0]}"},
173
+ "path": path_arg,
174
+ },
175
+ ["image", "prompt"],
176
+ )
177
+ def edit_image(image: str, prompt: str, model: str = IMAGE_MODELS[0], path: str = "") -> str:
178
+ body = {"model": model, "prompt": prompt, "image": {"url": data_url(image), "type": "image_url"},
179
+ "response_format": "b64_json"} # fmt: skip
180
+ saved = save_images(api, api.post("/images/edits", body), prompt, path)
181
+ return f"Saved the edited image: {saved[0]}"
182
+
183
+ def finish_video(request_id: str, prompt: str, path: str, wait: float) -> str:
184
+ deadline = time.monotonic() + wait
185
+ while True:
186
+ state = api.request("GET", f"/videos/{request_id}").json()
187
+ status = state.get("status")
188
+ if status == "done":
189
+ video = state.get("video") or {}
190
+ target = api.target(path, prompt or request_id, ".mp4")
191
+ target.write_bytes(api.download(video["url"]))
192
+ return f"Saved the video ({video.get('duration', '?')} s, {state.get('model', '')}): {target}"
193
+ if status in ("failed", "expired"):
194
+ raise ToolFailure(f"xAI couldn't make the video ({status}): {state.get('error') or state}")
195
+ if time.monotonic() > deadline:
196
+ return (
197
+ f"The video isn't ready yet (request {request_id}). Check later with "
198
+ f'video_status(request_id="{request_id}"), which downloads it when it\'s done.'
199
+ )
200
+ time.sleep(POLL)
201
+
202
+ @server.tool(
203
+ "generate_video",
204
+ "Generate a video with Grok Imagine, from a description and optionally a start image, and save it as "
205
+ "MP4. Takes a minute or more. Costs $0.02–0.08 per second of video.",
206
+ {
207
+ "prompt": {"type": "string", "description": "What happens in the video"},
208
+ "image": {"type": "string", "description": "A local image to start from (optional)"},
209
+ "duration": {"type": "integer", "description": "Seconds, 1–15 (default 8)"},
210
+ "aspect_ratio": {
211
+ "type": "string",
212
+ "enum": [r for r in ASPECT_RATIOS if r not in ("auto", "2:1", "1:2", "21:9")],
213
+ },
214
+ "resolution": {"type": "string", "enum": ["480p", "720p", "1080p"], "description": "Default 720p"},
215
+ "model": {"type": "string", "enum": VIDEO_MODELS, "description": f"Default {VIDEO_MODELS[0]}"},
216
+ "path": path_arg,
217
+ },
218
+ ["prompt"],
219
+ )
220
+ def generate_video(prompt: str, image: str = "", duration: int = 8, aspect_ratio: str = "16:9",
221
+ resolution: str = "720p", model: str = VIDEO_MODELS[0], path: str = "") -> str: # fmt: skip
222
+ body: dict = {"model": model, "prompt": prompt, "duration": max(1, min(15, int(duration))),
223
+ "aspect_ratio": aspect_ratio, "resolution": resolution} # fmt: skip
224
+ if image:
225
+ body["image"] = {"url": data_url(image)}
226
+ request_id = api.post("/videos/generations", body).get("request_id")
227
+ if not request_id:
228
+ raise ToolFailure("xAI didn't start the video")
229
+ return finish_video(str(request_id), prompt, path, VIDEO_WAIT)
230
+
231
+ @server.tool(
232
+ "video_status",
233
+ "Check a video that generate_video started and that wasn't ready yet; saves it when it's done.",
234
+ {"request_id": {"type": "string"}, "path": path_arg},
235
+ ["request_id"],
236
+ )
237
+ def video_status(request_id: str, path: str = "") -> str:
238
+ return finish_video(request_id, "", path, 0)
239
+
240
+ @server.tool(
241
+ "text_to_speech",
242
+ "Turn text into speech with an xAI voice and save it as an audio file. Billed per character.",
243
+ {
244
+ "text": {"type": "string", "description": "What to say (up to 60,000 characters)"},
245
+ "voice": {"type": "string", "enum": VOICES, "description": "Default eve"},
246
+ "language": {"type": "string", "description": "BCP-47 code such as en, de, fr, ja, or auto (default)"},
247
+ "format": {"type": "string", "enum": list(AUDIO_FORMATS), "description": "Default mp3"},
248
+ "speed": {"type": "number", "description": "1.0 is normal"},
249
+ "path": path_arg,
250
+ },
251
+ ["text"],
252
+ )
253
+ def text_to_speech(text: str, voice: str = "eve", language: str = "auto", format: str = "mp3",
254
+ speed: float = 1.0, path: str = "") -> str: # fmt: skip
255
+ codec = format if format in AUDIO_FORMATS else "mp3"
256
+ body = {"text": text, "voice_id": voice.lower(), "language": language or "auto",
257
+ "output_format": {"codec": codec}, "speed": float(speed or 1.0)} # fmt: skip
258
+ r = api.request("POST", "/tts", json=body)
259
+ duration = ""
260
+ if "json" in r.headers.get("Content-Type", ""):
261
+ data = r.json()
262
+ audio = base64.b64decode(data.get("audio", ""))
263
+ duration = f", {data['duration']:.1f} s" if isinstance(data.get("duration"), (int, float)) else ""
264
+ else:
265
+ audio = r.content
266
+ if not audio:
267
+ raise ToolFailure("xAI returned no audio")
268
+ target = api.target(path, text[:40], AUDIO_FORMATS[codec])
269
+ target.write_bytes(audio)
270
+ return f"Saved the speech ({voice}{duration}): {target}"
271
+
272
+ return server
273
+
274
+
275
+ def main(argv: list[str] | None = None) -> None:
276
+ parser = argparse.ArgumentParser(
277
+ prog="lcode-mcp-xai", description="MCP server for xAI's image, video and speech APIs (stdio)."
278
+ )
279
+ parser.add_argument("--output", default=os.environ.get("LCODE_XAI_OUTPUT", "generated"),
280
+ help="folder for the files it makes (default: generated/ in the project)") # fmt: skip
281
+ args = parser.parse_args(argv)
282
+ key = os.environ.get("XAI_API_KEY")
283
+ if not key:
284
+ parser.error("set XAI_API_KEY to an xAI API key (https://console.x.ai)")
285
+ build(XAI(key, os.environ.get("XAI_API_BASE", API), Path(args.output))).serve()
286
+
287
+
288
+ if __name__ == "__main__":
289
+ main()
@@ -0,0 +1,163 @@
1
+ import base64
2
+ import json
3
+ import threading
4
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5
+ from typing import ClassVar
6
+
7
+ import pytest
8
+
9
+ from lcode.mcp import catalog
10
+ from lcode.servers import xai
11
+
12
+ PNG = b"\x89PNG\r\n\x1a\n" + b"pixels"
13
+ JPEG = b"\xff\xd8\xff\xe0" + b"jpeg"
14
+ MP4 = b"\x00\x00\x00\x18ftypmp42video"
15
+ MP3 = b"ID3" + b"sound"
16
+
17
+
18
+ class FakeXAI(BaseHTTPRequestHandler):
19
+ """xAI's image, video and speech endpoints, as far as lcode-mcp-xai uses them."""
20
+
21
+ requests: ClassVar[list] = []
22
+ polls: ClassVar[int] = 0
23
+ video_ready_after: ClassVar[int] = 1
24
+
25
+ def log_message(self, *args):
26
+ pass
27
+
28
+ def send(self, status, body, content_type="application/json"):
29
+ data = body if isinstance(body, bytes) else json.dumps(body).encode()
30
+ self.send_response(status)
31
+ self.send_header("Content-Type", content_type)
32
+ self.send_header("Content-Length", str(len(data)))
33
+ self.end_headers()
34
+ self.wfile.write(data)
35
+
36
+ def authorized(self):
37
+ if self.headers.get("Authorization") != "Bearer good-key":
38
+ self.send(401, {"error": {"message": "Incorrect API key provided"}})
39
+ return False
40
+ return True
41
+
42
+ def do_POST(self):
43
+ body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
44
+ type(self).requests.append((self.path, body))
45
+ if not self.authorized():
46
+ return
47
+ if self.path == "/v1/images/generations":
48
+ if "forbidden" in body["prompt"]:
49
+ self.send(400, {"error": {"message": "Generated image rejected by content moderation."}})
50
+ return
51
+ images = [{"b64_json": base64.b64encode(PNG).decode()} for _ in range(body.get("n", 1))]
52
+ self.send(200, {"data": images})
53
+ elif self.path == "/v1/images/edits":
54
+ self.send(200, {"data": [{"url": f"http://{self.headers['Host']}/files/edited.jpg"}]})
55
+ elif self.path == "/v1/videos/generations":
56
+ self.send(200, {"request_id": "req-1"})
57
+ elif self.path == "/v1/tts":
58
+ self.send(200, {"audio": base64.b64encode(MP3).decode(), "content_type": "audio/mpeg", "duration": 1.5})
59
+ else:
60
+ self.send(404, {"error": "no such endpoint"})
61
+
62
+ def do_GET(self):
63
+ if self.path == "/files/edited.jpg":
64
+ self.send(200, JPEG, "image/jpeg")
65
+ elif self.path == "/files/video.mp4":
66
+ self.send(200, MP4, "video/mp4")
67
+ elif self.path == "/v1/videos/req-1" and self.authorized():
68
+ type(self).polls += 1
69
+ if type(self).polls <= type(self).video_ready_after:
70
+ self.send(200, {"status": "pending"})
71
+ else:
72
+ url = f"http://{self.headers['Host']}/files/video.mp4"
73
+ video = {"url": url, "duration": 6}
74
+ self.send(200, {"status": "done", "video": video, "model": "grok-imagine-video-1.5"})
75
+
76
+
77
+ @pytest.fixture
78
+ def api(tmp_path, monkeypatch):
79
+ FakeXAI.requests, FakeXAI.polls, FakeXAI.video_ready_after = [], 0, 1
80
+ monkeypatch.setattr(xai, "POLL", 0.01)
81
+ httpd = ThreadingHTTPServer(("127.0.0.1", 0), FakeXAI)
82
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
83
+ yield xai.XAI("good-key", f"http://127.0.0.1:{httpd.server_address[1]}/v1", tmp_path / "generated")
84
+ httpd.shutdown()
85
+
86
+
87
+ def call(server, name, **arguments):
88
+ result = server.call(name, arguments)
89
+ return result["content"][0]["text"], result["isError"]
90
+
91
+
92
+ def test_images_are_generated_and_saved(api, tmp_path):
93
+ server = xai.build(api)
94
+ assert [t.name for t in server.listed()] == [
95
+ "generate_image", "edit_image", "generate_video", "video_status", "text_to_speech"] # fmt: skip
96
+ text, error = call(server, "generate_image", prompt="A red fox in the snow", n=2, aspect_ratio="16:9")
97
+ assert not error and text.startswith("Saved 2 image(s) from grok-imagine-image-2.0:")
98
+ saved = sorted((tmp_path / "generated").glob("a-red-fox-in-the-snow-*.png"))
99
+ assert len(saved) == 2 and all(p.read_bytes() == PNG for p in saved)
100
+ path, body = FakeXAI.requests[0]
101
+ assert path == "/v1/images/generations" and body == {
102
+ "model": "grok-imagine-image-2.0", "prompt": "A red fox in the snow", "n": 2,
103
+ "response_format": "b64_json", "aspect_ratio": "16:9"} # fmt: skip
104
+ text, _ = call(server, "generate_image", prompt="logo", path=str(tmp_path / "assets" / "logo"))
105
+ assert text.endswith(f"- {tmp_path / 'assets' / 'logo.png'}") # the right extension is added
106
+ text, _ = call(server, "generate_image", prompt="logo", path=str(tmp_path / "assets" / "logo.png"))
107
+ assert text.endswith(f"- {tmp_path / 'assets' / 'logo-2.png'}") # nothing is overwritten
108
+
109
+
110
+ def test_an_image_is_edited_from_a_local_file(api, tmp_path):
111
+ source = tmp_path / "photo.png"
112
+ source.write_bytes(PNG)
113
+ text, error = call(xai.build(api), "edit_image", image=str(source), prompt="Make it a pencil sketch")
114
+ assert not error and text.startswith("Saved the edited image:") and text.endswith(".jpg")
115
+ _, body = FakeXAI.requests[0]
116
+ assert body["image"] == {"url": "data:image/png;base64," + base64.b64encode(PNG).decode(), "type": "image_url"}
117
+ text, error = call(xai.build(api), "edit_image", image=str(tmp_path / "missing.png"), prompt="x")
118
+ assert error and "doesn't exist" in text
119
+
120
+
121
+ def test_a_video_is_generated_waited_for_and_downloaded(api, tmp_path, monkeypatch):
122
+ start = tmp_path / "first.jpg"
123
+ start.write_bytes(JPEG)
124
+ server = xai.build(api)
125
+ text, error = call(server, "generate_video", prompt="The fox runs away", image=str(start), duration=6)
126
+ assert not error and text.startswith("Saved the video (6 s, grok-imagine-video-1.5):") and text.endswith(".mp4")
127
+ _, body = FakeXAI.requests[0]
128
+ assert body["duration"] == 6 and body["image"]["url"].startswith("data:image/jpeg;base64,")
129
+ assert next((tmp_path / "generated").glob("*.mp4")).read_bytes() == MP4 and FakeXAI.polls == 2
130
+ FakeXAI.polls, FakeXAI.video_ready_after = 0, 10**6
131
+ monkeypatch.setattr(xai, "VIDEO_WAIT", 0)
132
+ text, _ = call(server, "generate_video", prompt="slow")
133
+ assert "isn't ready yet (request req-1)" in text and 'video_status(request_id="req-1")' in text
134
+ FakeXAI.video_ready_after = 0
135
+ text, _ = call(server, "video_status", request_id="req-1", path=str(tmp_path / "clip.mp4"))
136
+ assert text.endswith(str(tmp_path / "clip.mp4")) and (tmp_path / "clip.mp4").read_bytes() == MP4
137
+
138
+
139
+ def test_speech_is_saved_as_audio(api, tmp_path):
140
+ text, error = call(xai.build(api), "text_to_speech", text="Hello from lcode", voice="Leo", language="en")
141
+ assert not error and text.startswith("Saved the speech (Leo, 1.5 s):") and text.endswith(".mp3")
142
+ _, body = FakeXAI.requests[0]
143
+ assert body == {"text": "Hello from lcode", "voice_id": "leo", "language": "en", "output_format": {"codec": "mp3"},
144
+ "speed": 1.0} # fmt: skip
145
+ assert next((tmp_path / "generated").glob("hello-from-lcode-*.mp3")).read_bytes() == MP3
146
+
147
+
148
+ def test_xai_errors_reach_the_model(api, tmp_path):
149
+ text, error = call(xai.build(api), "generate_image", prompt="something forbidden")
150
+ assert error and text == "xAI error 400: Generated image rejected by content moderation."
151
+ bad = xai.XAI("wrong", api.base, tmp_path)
152
+ text, error = call(xai.build(bad), "text_to_speech", text="hi")
153
+ assert error and "xAI refused the request (401): Incorrect API key provided" in text and "XAI_API_KEY" in text
154
+
155
+
156
+ def test_the_preset_asks_for_the_key():
157
+ preset = catalog.load()["xai"]
158
+ [key] = preset.inputs
159
+ assert key.var == "XAI_API_KEY" and key.secret
160
+ server = catalog.build(preset, {"XAI_API_KEY": "typed-" + "key"})
161
+ assert server["env"] == {"XAI_API_KEY": "typed-key"} and server["args"][-1] == "lcode-mcp-xai"
162
+ assert catalog.build(preset, {"XAI_API_KEY": None})["env"] == {"XAI_API_KEY": "${XAI_API_KEY}"}
163
+ assert server["timeout"] == 900
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes