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.
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/CHANGELOG.md +13 -1
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/PKG-INFO +3 -2
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/README.md +2 -1
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/mcp.md +31 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/quickstart.md +1 -1
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/pyproject.toml +2 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/catalog.toml +21 -0
- lcode_cli-0.22.0/src/lcode/servers/xai.py +289 -0
- lcode_cli-0.22.0/tests/test_xai.py +163 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.editorconfig +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.github/dependabot.yml +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.gitignore +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/AGENTS.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/LICENSE +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/SECURITY.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/action.yml +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/agents.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/assets/extra.css +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/assets/logo.svg +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/assets/social-preview.png +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/automation.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/bench.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/changelog.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/commands.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/configuration.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/contributing.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/editors.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/git.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/hooks.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/how-it-works.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/index.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/installation.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/lsp.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/memory.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/models.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/sandbox.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/search.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/servers.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/docs/usage.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/examples/README.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/examples/api/fix_tests.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/examples/commands/review.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/examples/skills/commit-message/SKILL.md +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/install.sh +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/mkdocs.yml +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/overrides/main.html +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/scripts/record_demo.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/scripts/social-preview.html +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/acp.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/action.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/agent.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/api.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/backends.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/bench.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/bench_backend.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/cli.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/codesearch.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/config.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/context.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/extensions.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/frontmatter.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/gitflow.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/hooks.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/jobs.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/limits.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/lsp.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/commands.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/config.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/manager.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/protocol.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/server.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/memory.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/models.toml +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/notify.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/ollama.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/permissions.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/planning.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/render.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/repl.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/repomap.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/sandbox.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/servers/__init__.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/servers/encord.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/servers/valohai.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/subagents.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/tools.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/update.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/vision.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/src/lcode/web.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/conftest.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/fake_lsp_server.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_acp.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_action.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_agent.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_api.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_backends.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_bench.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_catalog.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_cli.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_codesearch.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_config.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_context.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_extensions.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_gitflow.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_hooks.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_jobs.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_lsp.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_mcp.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_memory.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_permissions.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_plan_mode.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_repomap.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_sandbox.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_servers.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_sessions.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_subagents.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_tools.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_update.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_vision.py +0 -0
- {lcode_cli-0.21.0 → lcode_cli-0.22.0}/tests/test_web.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
@@ -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"]
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|