jonaspauleta / laravel-ai-moonshot
Moonshot AI (Kimi K2) provider for the official Laravel AI SDK.
Package info
github.com/jonaspauleta/laravel-ai-moonshot
pkg:composer/jonaspauleta/laravel-ai-moonshot
Requires
- php: ^8.4
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/http: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- laravel/ai: ^0.9.0
Requires (Dev)
- driftingly/rector-laravel: ^2.2
- larastan/larastan: ^3.0
- laravel/pint: ^1.25
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- rector/rector: ^2.2
README
Moonshot AI (Kimi K2) provider for the official Laravel AI SDK.
Moonshot's API is OpenAI-compatible (POST https://api.moonshot.ai/v1/chat/completions), so this driver plugs directly into the SDK's TextProvider / StepTextGateway contracts and behaves like any first-party provider. Agent classes, streaming, tool calling, broadcasting, and queued prompts work through Laravel AI's TextGenerationLoop.
Features
- ✅ Text generation (
prompt()) - ✅ Streaming responses (
stream(),broadcast(),broadcastOnQueue()) - ✅ Tool calling (function calling)
- ✅ Structured outputs (schema-constrained JSON via
response_format: json_schema, strict mode) - ✅ Image attachments (base64, remote URL, local file, stored disk,
UploadedFile) - ✅ Document Q&A via Moonshot Files API (PDF, DOC, XLSX, PPTX, …) — server-side OCR + extraction
- ✅ Kimi thinking mode with
reasoning_contentdeltas surfaced asReasoningStart/ReasoningDelta/ReasoningEndstream events - ✅ Multi-turn reasoning persistence (
thinking.keep = all) - ✅ Per-tier model overrides (
default,cheapest,smartest) - ✅ Custom base URL (proxy / self-hosted compatible)
- ✅ PHPStan level max, Pest 4, Pint, Rector
Capability matrix
| Capability | Status |
|---|---|
| Text generation | Supported |
| Streaming | Supported |
| Tool calling | Supported — function tools + Moonshot's $web_search builtin (via WebSearch ProviderTool) |
| Image input | Supported |
| Document Q&A | Supported via Moonshot Files API |
| Thinking mode (Kimi reasoning) | Supported |
| Structured output | Supported — response_format: json_schema, strict mode (MFJS); deferred until after the tool loop when tools are present |
| Provider-hosted tools | $web_search only; Convert and Fetch use Moonshot's Formulas API |
| Embeddings | Not supported |
| Image generation / audio / transcription / reranking | Not supported |
Limitations: no embeddings, no image generation, no audio/transcription/reranking, no provider-hosted tools other than Moonshot's
$web_searchbuiltin (see Web search), and documents must use the Moonshot Files API (withMoonshotFile()/MoonshotFiles) instead of Laravel AI genericDocumentattachments. See Not supported for details.
Package maturity: this package tracks the evolving
laravel/aiSDK, which is still on0.x. Newlaravel/aiminor versions are adopted only after a compatibility review and may require a package major. See Versioning.
Requirements
| Requirement | Version |
|---|---|
| PHP | ^8.4 (8.4, 8.5) |
| Laravel | 12.x | 13.x |
laravel/ai |
^0.9.0 |
laravel/ai 0.9.x requires illuminate/* ^12.0|^13.0, so Laravel 11 is not supported. CI exercises every PHP × Laravel combination above on each push and PR.
Installation
composer require jonaspauleta/laravel-ai-moonshot
The service provider is auto-discovered. There are no migrations or config files to publish — configuration lives in your application's existing config/ai.php.
60-second quick start
composer require jonaspauleta/laravel-ai-moonshot
# .env MOONSHOT_API_KEY=sk-...
// config/ai.php 'providers' => [ 'moonshot' => [ 'driver' => 'moonshot', 'name' => 'moonshot', 'key' => env('MOONSHOT_API_KEY'), ], ],
use function Laravel\Ai\agent; $response = agent('You are a helpful assistant.') ->prompt('Explain Moonshot Kimi K2 in one sentence.', provider: 'moonshot'); echo $response->text;
That's the minimum. See Configuration for per-tier model overrides, custom base URL, and making Moonshot the default provider.
Configuration
Add your API key to .env:
MOONSHOT_API_KEY=sk-...
Register the provider in config/ai.php:
'providers' => [ // ... 'moonshot' => [ 'driver' => 'moonshot', 'name' => 'moonshot', 'key' => env('MOONSHOT_API_KEY'), // Optional. Defaults shown. 'url' => env('MOONSHOT_URL', 'https://api.moonshot.ai/v1'), // Optional per-tier model overrides. 'models' => [ 'text' => [ 'default' => 'kimi-k2.6', 'cheapest' => 'kimi-k2.5', 'smartest' => 'kimi-k2.6', ], ], ], ],
To make Moonshot the default provider for the whole application:
'default' => env('AI_PROVIDER', 'moonshot'),
Configuration reference
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
driver |
string |
yes | — | Must be 'moonshot'. Resolves the provider in AiManager. |
name |
string |
yes | — | Display name returned by Provider::name(). |
key |
string |
yes | — | Your Moonshot API key. |
url |
string |
no | https://api.moonshot.ai/v1 |
API base URL. Override for proxies or regional endpoints. |
models.text.default |
string |
no | kimi-k2.6 |
Used by Provider::defaultTextModel(). |
models.text.cheapest |
string |
no | kimi-k2.5 |
Used by Provider::cheapestTextModel() and the #[UseCheapestModel] attribute. |
models.text.smartest |
string |
no | kimi-k2.6 |
Used by Provider::smartestTextModel() and the #[UseSmartestModel] attribute. There is no separate thinking SKU — enable thinking per-call via providerOptions. |
How it works
This package ships MoonshotProvider, backed by a MoonshotGateway that implements Laravel AI 0.9's StepTextGateway. Each gateway call performs one POST /v1/chat/completions step and returns a StepResponse. Laravel AI's TextGenerationLoop owns multi-step orchestration, while MoonshotTextGenerationLoop only resolves Moonshot's $web_search, Convert, and Fetch calls alongside ordinary Laravel AI tools.
Streaming reads the SSE body chunk-by-chunk and maps Moonshot's payload to the SDK's stream events:
delta.content→TextStart/TextDelta/TextEnddelta.reasoning_content→ReasoningStart/ReasoningDelta/ReasoningEnd(Kimi thinking mode)delta.tool_calls→ buffered, thenToolCall; the generation loop emitsToolResult- step usage → one cumulative terminal
StreamEndfrom Laravel AI's generation loop
ReasoningEnd fires before the first TextStart when the model transitions out of thinking. The gateway emits TextGenerationStepCompleted after each completed streamed step so cancellation metering can observe step usage without adding another StreamEnd.
Usage
Quick start (ad-hoc agent)
use function Laravel\Ai\agent; $response = agent('You are a helpful assistant.') ->prompt('Explain Moonshot Kimi K2 in one sentence.', provider: 'moonshot'); echo $response->text;
Agent class
Generate one with php artisan make:agent RaceEngineer (the generator ships with laravel/ai), or write it by hand:
namespace App\Ai\Agents; use Laravel\Ai\Attributes\Provider; use Laravel\Ai\Contracts\Agent; use Laravel\Ai\Contracts\Conversational; use Laravel\Ai\Contracts\HasTools; use Laravel\Ai\Promptable; use Stringable; #[Provider('moonshot')] class RaceEngineer implements Agent, Conversational, HasTools { use Promptable; public function instructions(): Stringable|string { return 'You are a Formula 1 race engineer. Be concise and technical.'; } public function messages(): iterable { return []; } public function tools(): iterable { return []; } }
$response = RaceEngineer::make() ->prompt('Best dry setup for the Nordschleife in a GT3?'); echo $response->text;
Streaming
$stream = RaceEngineer::make() ->stream('Walk me through a flying lap at Spa.', provider: 'moonshot'); foreach ($stream as $event) { // TextStart, TextDelta, TextEnd, ReasoningStart, ReasoningDelta, ReasoningEnd, ... }
Broadcasting (Echo / Reverb)
use Illuminate\Broadcasting\PrivateChannel; RaceEngineer::make() ->broadcastOnQueue( 'What is the best setup for the Nordschleife?', new PrivateChannel("agent.{$user->id}.{$requestId}"), provider: 'moonshot', model: 'kimi-k2.6', );
Tool calling
use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Ai\Contracts\Tool; use Laravel\Ai\Tools\Request; class GetWeather implements Tool { public function description(): string { return 'Get the current weather for a city.'; } public function schema(JsonSchema $schema): array { return [ 'city' => $schema->string()->description('City name')->required(), ]; } public function handle(Request $request): string { $city = $request->string('city'); return "Sunny, 24°C in {$city}."; } }
use function Laravel\Ai\agent; $response = agent( instructions: 'Use tools when needed.', tools: [new GetWeather], )->prompt('What is the weather in Lisbon?', provider: 'moonshot');
Structured output
Moonshot supports OpenAI-compatible Structured Outputs on kimi-k2.5 and kimi-k2.6. When a schema is provided, the gateway sends response_format: { type: 'json_schema', json_schema: { name, schema, strict: true } } and the response is decoded into a StructuredTextResponse whose ->structured is an array matching the schema.
Implement HasStructuredOutput on an agent:
namespace App\Ai\Agents; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Ai\Attributes\Provider; use Laravel\Ai\Contracts\Agent; use Laravel\Ai\Contracts\HasStructuredOutput; use Laravel\Ai\Promptable; #[Provider('moonshot')] final class ExtractPerson implements Agent, HasStructuredOutput { use Promptable; public function instructions(): string { return 'Extract the person mentioned in the text.'; } public function schema(JsonSchema $schema): array { return [ 'name' => $schema->string()->description('Full name')->required(), 'age' => $schema->integer()->required(), ]; } }
$response = ExtractPerson::make() ->prompt('Ada Lovelace was 36 when she died.'); $response->structured; // ['name' => 'Ada Lovelace', 'age' => 36]
Or for one-offs, use StructuredAnonymousAgent directly (see the Laravel AI SDK docs).
MFJS limitations. Moonshot's strict mode requires MFJS — a JSON-Schema subset. Unsupported keywords include
format,pattern,oneOf,allOf,minLength/maxLength,minimum/maximum,title,$comment, andprefixItems. Schemas are passed through verbatim — Moonshot returns HTTP 400 on incompatible keywords.
Streaming. Streaming + schema sends
response_format: json_schemaon tool-free requests. The SDK does not emitObjectDeltaevents — accumulateTextDeltas andjson_decodeatStreamEnd. Non-streaming returns a decodedStructuredTextResponseautomatically.
Tools + structured output (deferred json_schema)
Do not rely on tools and a strict response_format landing on the same request for Kimi. When both are present, Kimi frequently returns an empty schema-valid JSON object and never invokes a tool (observed in production on kimi-k2.6).
The gateway therefore defers structured output by default when an agent combines tools with HasStructuredOutput:
- Tool-loop steps send
tools+tool_choice: autoand omitresponse_format. The SDK's schema instructions stay in the system prompt, so the model keeps steering toward the schema. - After the tool loop settles,
MoonshotTextGenerationLoopappends one final tool-free request withresponse_format: json_schema(strict) over the full conversation, so the terminal generation is schema-enforced and cannot invite more tool calls.
The finalize step costs one extra request per structured tool-loop generation (its prompt is largely served from Moonshot's context cache). If the step budget runs out while the model is still requesting tools, the unexecuted tool_calls are stripped from the finalize request so Moonshot does not reject the history.
Deferral only applies to non-streaming generation with tools present; schema-only requests keep the original single-request behavior. Opt out (and restore response_format on every step) via provider config:
'moonshot' => [ // ... 'defer_structured_output' => false, ],
Streaming with tools + schema omits response_format on tool-carrying steps but has no finalize step; the final text is prompt-steered JSON, so decode it yourself at StreamEnd.
Web search
Moonshot ships a server-side web search builtin ($web_search) that you can drop into any Moonshot agent by passing the SDK's generic Laravel\Ai\Providers\Tools\WebSearch provider tool. The gateway translates it to Moonshot's builtin_function payload and auto-replies to the model's $web_search tool_call with the model's own arguments — Kimi runs the actual search server-side.
use function Laravel\Ai\agent; use Laravel\Ai\Providers\Tools\WebSearch; $response = agent( instructions: 'Cite the sources you find on the web.', tools: [new WebSearch], )->prompt('Latest F1 driver standings.', provider: 'moonshot');
Streaming works the same way — the $web_search round-trip emits a ToolCall event followed by a ToolResult event whose result is the echoed argument JSON.
Caveats
- The SDK's
WebSearchknobs —maxSearches,allowedDomains,location()— are silently dropped. Kimi exposes no client-side configuration for the builtin.- Other
ProviderToolsubclasses (WebFetch,FileSearch, …) still throwUnsupportedProviderToolException. Moonshot has no equivalent builtins.- See the Kimi web search guide for billing and rate-limit details —
$web_searchinvocations consume search-count tokens on top of normal completion usage.
Image attachments
use Laravel\Ai\Files\RemoteImage; use function Laravel\Ai\agent; $response = agent('Describe the image.') ->prompt( prompt: 'What do you see?', attachments: [new RemoteImage('https://example.com/photo.jpg')], provider: 'moonshot', );
Supported attachment types: Base64Image, RemoteImage, LocalImage, StoredImage, and Illuminate\Http\UploadedFile (when the MIME type is image/jpeg|png|gif|webp). Document attachments are not supported through the SDK's generic Document contract — use the Files API below instead.
Document Q&A (PDF, DOC, XLSX, …)
Moonshot's chat/completions endpoint does not accept document attachments. Document Q&A goes through Moonshot's separate Files API at POST /v1/files, which performs server-side text extraction (including OCR for scanned PDFs) and returns the extracted text. That text is then injected as a leading prompt block on subsequent chat completions.
Supported formats include .pdf, .txt, .csv, .doc, .docx, .xls, .xlsx, .ppt, .pptx, .md, .json, .html, .epub, plus most code/config formats. See Moonshot's official format list for the full catalog.
Ergonomic API: withMoonshotFile()
Add the InjectsMoonshotFiles trait to any agent class that already uses Promptable. Implement HasMiddleware so the trait's middleware can prepend the extracted document text to the user prompt:
namespace App\Ai\Agents; use Jonaspauleta\LaravelAiMoonshot\Concerns\InjectsMoonshotFiles; use Laravel\Ai\Attributes\Provider; use Laravel\Ai\Contracts\Agent; use Laravel\Ai\Contracts\HasMiddleware; use Laravel\Ai\Promptable; #[Provider('moonshot')] final class ContractAnalyst implements Agent, HasMiddleware { use Promptable; use InjectsMoonshotFiles; public function instructions(): string { return 'You analyze contracts and surface risky clauses.'; } } $response = ContractAnalyst::make() ->withMoonshotFile('storage/app/contracts/2026-renewal.pdf') ->withMoonshotFile($uploadedFile, label: 'Refund Policy') ->prompt('What are the riskiest clauses?');
Each withMoonshotFile() call uploads the file, fetches its extracted text, and appends a labelled block (Document: <label>\n<content>) that the trait's middleware prepends to your next prompt. Call it as many times as you need; ordering is preserved.
If your agent already implements HasMiddleware and overrides middleware(), alias the trait method and merge:
use InjectsMoonshotFiles { middleware as moonshotFilesMiddlewareDefault; } public function middleware(): array { return [...$this->moonshotFilesMiddlewareDefault(), new MyOtherMiddleware()]; }
Lower-level service: MoonshotFiles
When you need control over the file lifecycle (listing, deleting, retrieving extracted content directly), inject the MoonshotFiles service:
use Jonaspauleta\LaravelAiMoonshot\Files\MoonshotFiles; $files = app(MoonshotFiles::class); $file = $files->upload('contract.pdf'); $text = $files->content($file->id); // Later: list / delete foreach ($files->list() as $entry) { $files->delete($entry->id); }
Run php artisan ai:moonshot:files to inspect uploaded files; php artisan ai:moonshot:files --delete=<id> to remove them.
Limits
- 1,000 files per account
- 100 MB maximum per file (the service rejects oversize uploads before the network call)
- 10 GB total storage across all uploads
- Extraction is currently free, but rate-limited at peak usage
Security: treat document content as untrusted input
Extracted document text is untrusted user input — a malicious PDF can attempt prompt injection by mimicking system instructions. The withMoonshotFile() ergonomic API mitigates this by prefixing each block with Document: <label> so the model can distinguish your trusted instructions from document text.
The official Moonshot documentation injects extracted content as a system message. The Laravel AI SDK's MessageRole enum has no system case (the instructions() slot is reserved), so this package prepends the document block to the user prompt instead. The Document: <label> framing is what makes the prompt-injection mitigation work — keep the labels meaningful and avoid putting model-controlled strings in them.
Thinking mode
There is no separate thinking model. kimi-k2.6 and kimi-k2.5 both expose reasoning via the thinking request parameter. The smartest tier defaults to kimi-k2.6 with thinking enabled at call-site via HasProviderOptions.
When thinking is enabled, Kimi streams chain-of-thought through reasoning_content deltas. This package surfaces them as standard Laravel AI SDK stream events — ReasoningStart → ReasoningDelta → ReasoningEnd — and guarantees ReasoningEnd fires before the first TextStart.
Pass Moonshot's native thinking payload by implementing HasProviderOptions on your agent. The gateway merges the array into the request body verbatim:
namespace App\Ai\Agents; use Laravel\Ai\Attributes\Provider; use Laravel\Ai\Contracts\Agent; use Laravel\Ai\Contracts\Conversational; use Laravel\Ai\Contracts\HasProviderOptions; use Laravel\Ai\Contracts\HasTools; use Laravel\Ai\Enums\Lab; use Laravel\Ai\Promptable; use Stringable; #[Provider('moonshot')] class ThinkingAgent implements Agent, Conversational, HasProviderOptions, HasTools { use Promptable; public function instructions(): Stringable|string { return 'Think step by step before answering.'; } public function messages(): iterable { return []; } public function tools(): iterable { return []; } public function providerOptions(Lab|string $provider): array { return $provider === 'moonshot' // type: 'enabled' | 'disabled' // keep: 'all' — kimi-k2.6 only, preserves reasoning across multi-turn conversations ? ['thinking' => ['type' => 'enabled', 'keep' => 'all']] : []; } }
Note: the
keep: 'all'flag iskimi-k2.6-specific. For one-off thinking onkimi-k2.5, send['thinking' => ['type' => 'enabled']]withoutkeep.
Models
The defaults track Moonshot's public model catalog. Override per-tier in config/ai.php if Moonshot renames models or you want to pin a specific snapshot.
| Tier | Default ID | Used by |
|---|---|---|
| Default | kimi-k2.6 |
Provider::defaultTextModel() |
| Cheapest | kimi-k2.5 |
#[UseCheapestModel], cheapestTextModel() |
| Smartest | kimi-k2.6 |
#[UseSmartestModel], smartestTextModel(). Same SKU as default — Moonshot has no separate thinking model. Enable thinking per-call via providerOptions(['thinking' => ['type' => 'enabled']]). |
Run php artisan ai:moonshot:models against a configured environment to print the live catalog (model IDs, context length, image/video/reasoning support). Pass --json for the raw response.
You can also pass an explicit model per call: ->prompt('...', model: 'kimi-k2.6').
Not supported
The Moonshot API does not expose endpoints for the following capabilities at the time of release; this package will throw or fail-fast rather than fake them:
- Embeddings — Moonshot has no embeddings endpoint.
- Image generation, audio, transcription, reranking — text only.
- Provider-hosted tools other than
WebSearch(e.g.WebFetch,FileSearch) — throwsUnsupportedProviderToolExceptionif passed.WebSearchis supported via Moonshot's$web_searchbuiltin, while this package'sConvertandFetchtools run separately through Moonshot's Formulas API. - Document attachments via the SDK's generic
Documentcontract — usewithMoonshotFile()or theMoonshotFilesservice instead, which goes through Moonshot's/v1/filesextraction endpoint (see Document Q&A).
Troubleshooting
| Symptom | Cause / fix |
|---|---|
Driver [moonshot] is not supported. |
MoonshotServiceProvider did not boot. Ensure auto-discovery is on, or register manually in config/app.php providers. |
HTTP 401 Invalid API key |
MOONSHOT_API_KEY missing or wrong. Verify with dd(config('ai.providers.moonshot.key')) after a php artisan config:clear. |
HTTP 400 model not found for kimi-k2.6 (or any default tier) |
Moonshot renamed or retired the default. Pin a working model under config/ai.php providers.moonshot.models.text.{default,cheapest,smartest}. |
UnsupportedProviderToolException: Moonshot does not support [WebFetch] provider tools. |
You passed an unsupported ProviderTool subclass (e.g. WebFetch, FileSearch). For web search use Laravel\Ai\Providers\Tools\WebSearch (mapped to Moonshot's $web_search builtin). For everything else, use plain function tools. |
Document attachment via SDK's generic Document is silently ignored or rejected |
Generic Document attachments are intentionally unsupported. Use withMoonshotFile() (or the MoonshotFiles service) — this routes through Moonshot's Files API, which performs server-side extraction. See Document Q&A. |
HTTP 400 with structured outputs (e.g. unsupported keyword) |
Moonshot's strict mode requires MFJS — a JSON-Schema subset. Drop unsupported keywords (format, pattern, oneOf, allOf, minLength/maxLength, etc.) from your schema. See Structured output. |
| Streaming hangs on long thinking-mode responses | Default per-request timeout is 60s. Pass timeout: to the SDK's stream() call or raise it in the gateway's HTTP client invocation. |
Http::fake() in tests does not intercept the request |
Fake key must include the full base URL: 'api.moonshot.ai/v1/chat/completions' => Http::response(...). The Laravel HTTP client applies the base URL. |
MoonshotFilesException: ... extraction failed for [file-...] |
Moonshot could not extract text from the uploaded file. Check the file is one of the supported formats and not corrupted. Inspect status via php artisan ai:moonshot:files. |
MoonshotFilesException: Moonshot Files API quota exceeded |
You hit the 1,000-files / 10 GB account limit, or per-minute rate limit at peak. Delete unused files via php artisan ai:moonshot:files --delete=<id>. |
MoonshotFilesException: File of N bytes exceeds Moonshot Files API limit of 104857600 |
The file is larger than 100 MB. Split it or extract relevant pages client-side before uploading. |
Testing
composer test # Pest composer analyse # PHPStan level max composer format # Pint composer quality # rector + pint + phpstan + pest
CI runs Pint, Rector (dry-run), PHPStan, and Pest on every push and PR — see .github/workflows/run-tests.yml.
Versioning
This package follows Semantic Versioning, but the upstream laravel/ai SDK is still on 0.x — its minor bumps may include breaking changes. To keep our SemVer promise honest:
- We pin to a specific
laravel/aiminor:composer.jsonrequires^0.9.0(allows0.9.xpatches but not0.10.0). - Each new
laravel/aiminor lands after a compatibility audit. Breaking SDK migrations may require a new package major. - Truly breaking changes here (renaming public classes, removing a public method, dropping a PHP version) bump our major.
If laravel/ai reaches 1.0.0 we will widen the constraint to ^1.0 and follow standard SemVer ranges.
Changelog
See CHANGELOG.md for release notes. The project follows Keep a Changelog and Semantic Versioning.
Contributing
Contributions are welcome. Please:
- Open an issue first for larger changes so we can discuss the approach.
- Run
composer qualitybefore pushing — CI will fail otherwise. - Use Conventional Commits for commit messages.
- Add Pest tests for new behavior.
tests/Feature/MoonshotStreamTest.phpis a good template for HTTP-faked gateway tests.
Security
If you discover a security vulnerability, please email jpaulo4799santos@gmail.com instead of opening a public issue. You'll get a response within 48 hours. See SECURITY.md for the full policy.
Credits
- João Paulo Santos
- The Laravel AI SDK team —
MoonshotGatewayis modeled directly on the SDK's first-partyDeepSeekGateway.
License
The MIT License (MIT). See LICENSE.md.