Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Utopia Framework requires PHP 8.0 or later. We recommend using the latest PHP ve

## Features

- **Multiple AI Providers** - Support for OpenAI, Anthropic, Deepseek, Perplexity, and XAI APIs
- **Multiple AI Providers** - Support for OpenAI, Anthropic, Deepseek, Moonshot, Perplexity, and XAI APIs
- **Flexible Message Types** - Support for text and structured content in messages
- **Conversation Management** - Easy-to-use conversation handling between agents and users
- **Model Selection** - Choose from various AI models (GPT-4, Claude 3, Deepseek Chat, Sonar, Grok, etc.)
Expand Down Expand Up @@ -117,6 +117,27 @@ Available Deepseek Models:
- `MODEL_DEEPSEEK_CHAT`: General-purpose chat model
- `MODEL_DEEPSEEK_CODER`: Specialized for code-related tasks

#### Moonshot

```php
use Utopia\Agents\Adapters\Moonshot;

$moonshot = new Moonshot(
apiKey: 'your-api-key',
model: Moonshot::MODEL_KIMI_K2_5,
maxTokens: 2048,
temperature: 1.0
);
```

Available Moonshot Models:
- `MODEL_KIMI_K2_5`: General-purpose Kimi model for long-context chat and coding workflows

Moonshot notes:
- The current adapter is text-only and follows the library's existing string-based message abstraction
- Structured output uses JSON mode with schema guidance in the prompt, not strict provider-side JSON Schema validation
- `kimi-k2.5` expects the default temperature of `1.0`

#### Perplexity

```php
Expand Down
223 changes: 223 additions & 0 deletions src/Agents/Adapters/Moonshot.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
<?php

namespace Utopia\Agents\Adapters;

use Utopia\Agents\Message;
use Utopia\Agents\Messages\Text;
use Utopia\Fetch\Chunk;
use Utopia\Fetch\Client;

class Moonshot extends OpenAI
{
/**
* Default Moonshot API endpoint
*/
protected const ENDPOINT = 'https://api.moonshot.ai/v1/chat/completions';

/**
* Kimi K2.5 - General-purpose Moonshot model optimized for long-context chat and coding workflows
*/
public const MODEL_KIMI_K2_5 = 'kimi-k2.5';

/**
* Create a new Moonshot adapter
*
* @throws \Exception
*/
public function __construct(
string $apiKey,
string $model = self::MODEL_KIMI_K2_5,
int $maxTokens = 1024,
float $temperature = 1.0,
?string $endpoint = null,
int $timeout = 90000
) {
parent::__construct(
$apiKey,
$model,
$maxTokens,
$temperature,
$endpoint ?? self::ENDPOINT,
$timeout
);
}

/**
* Check if the model supports structured output.
*
* Moonshot currently exposes JSON mode rather than OpenAI-style strict
* json_schema transport, so we keep schema support enabled and adapt the
* request format inside send().
*/
public function isSchemaSupported(): bool
{
return true;
}

/**
* Send a message to the Moonshot API.
*
* @param array<Message> $messages
*
* @throws \Exception
*/
public function send(array $messages, ?callable $listener = null): Message
{
$agent = $this->getAgent();
if ($agent === null) {
throw new \Exception('Agent not set');
}

$client = new Client();
$client
->setTimeout($this->timeout)
->addHeader('authorization', 'Bearer '.$this->apiKey)
->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON);

$formattedMessages = [];
foreach ($messages as $message) {
if (empty($message->getRole()) || empty($message->getContent())) {
throw new \Exception('Invalid message format');
}

$formattedMessages[] = [
'role' => $message->getRole(),
'content' => $message->getContent(),
];
}

$instructions = [];
foreach ($agent->getInstructions() as $name => $content) {
$text = is_array($content) ? implode("\n", $content) : $content;
$instructions[] = '# '.$name."\n\n".$text;
}

$systemMessage = $agent->getDescription().
(empty($instructions) ? '' : "\n\n".implode("\n\n", $instructions));

$schema = $agent->getSchema();
if ($schema !== null) {
$systemMessage .= "\n\nUSE THE JSON SCHEMA BELOW TO GENERATE A VALID JSON RESPONSE:\n".$schema->toJson();
}

if (! empty($systemMessage)) {
array_unshift($formattedMessages, [
'role' => 'system',
'content' => $systemMessage,
]);
}

$payload = [
'model' => $this->model,
'messages' => $formattedMessages,
'stream' => $schema === null,
'max_completion_tokens' => $this->maxTokens,
];

$temperature = $this->temperature;
if (! $this->usesDefaultTemperatureOnly()) {
$payload['temperature'] = $temperature;
}

if ($schema !== null) {
$payload['response_format'] = [
'type' => 'json_object',
];
}

$content = '';

if ($payload['stream']) {
$response = $client->fetch(
$this->endpoint,
Client::METHOD_POST,
$payload,
[],
function ($chunk) use (&$content, $listener) {
/** @var Chunk $chunk */
$content .= $this->process($chunk, $listener);
}
);

if ($response->getStatusCode() >= 400) {
throw new \Exception(
ucfirst($this->getName()).' API error: '.$content,
$response->getStatusCode()
);
}
} else {
$response = $client->fetch(
$this->endpoint,
Client::METHOD_POST,
$payload,
);
$body = $response->getBody();

if ($response->getStatusCode() >= 400) {
$json = is_string($body) ? json_decode($body, true) : null;
$content = $this->formatErrorMessage($json);
throw new \Exception(
ucfirst($this->getName()).' API error: '.$content,
$response->getStatusCode()
);
}

$json = is_string($body) ? json_decode($body, true) : null;
$choices = is_array($json) && isset($json['choices']) && is_array($json['choices']) ? $json['choices'] : [];
$firstChoice = isset($choices[0]) && is_array($choices[0]) ? $choices[0] : [];
$message = isset($firstChoice['message']) && is_array($firstChoice['message']) ? $firstChoice['message'] : [];
if (isset($message['content']) && is_string($message['content'])) {
$content = $message['content'];
} else {
throw new \Exception('Invalid response format received from the API');
}
}

return new Text($content);
}

/**
* Get available models.
*
* @return array<string>
*/
public function getModels(): array
{
return [
self::MODEL_KIMI_K2_5,
];
}

/**
* Moonshot expects max_completion_tokens for kimi-k2.5.
*/
protected function usesMaxCompletionTokens(): bool
{
return true;
}

/**
* kimi-k2.5 only supports the default temperature.
*/
protected function usesDefaultTemperatureOnly(): bool
{
if ($this->temperature !== 1.0 && ! $this->hasWarnedTemperatureOverride) {
$this->hasWarnedTemperatureOverride = true;
error_log(
"Moonshot adapter warning: model '{$this->model}' only supports temperature=1.0. "
."Ignoring provided value {$this->temperature}. "
.'Set temperature to 1.0 to remove this warning.'
);
}

return true;
}

/**
* Get the adapter name.
*/
public function getName(): string
{
return 'moonshot';
}
}
30 changes: 30 additions & 0 deletions tests/Agents/Conversation/ConversationMoonshotTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace Utopia\Tests\Agents\Conversation;

use Utopia\Agents\Adapter;
use Utopia\Agents\Adapters\Moonshot;

class ConversationMoonshotTest extends ConversationBase
{
protected function createAdapter(): Adapter
{
$apiKey = getenv('LLM_KEY_MOONSHOT');

if ($apiKey === false || empty($apiKey)) {
throw new \RuntimeException('LLM_KEY_MOONSHOT environment variable is not set');
}

return new Moonshot(
$apiKey,
Moonshot::MODEL_KIMI_K2_5,
1024,
1.0
);
}

protected function getAgentDescription(): string
{
return 'Test Moonshot Agent Description';
}
}
Loading