Skip to content

Commit 915b26a

Browse files
authored
Merge pull request #11 from utopia-php/feat-schema
feat: Add Schema and structured output support
2 parents f508aad + 9b74cda commit 915b26a

14 files changed

Lines changed: 666 additions & 31 deletions

File tree

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,30 @@ $imageMessage = new Image($imageBinaryContent);
192192
$mimeType = $imageMessage->getMimeType(); // Get the MIME type of the image
193193
```
194194

195+
## Schema and Schema Objects
196+
197+
You can use the `Schema` class to define a schema for a structured output. The `Schema` class utilizes `SchemaObject`s to define each property of the schema, following the [JSON Schema](https://json-schema.org/) format.
198+
199+
```php
200+
use Utopia\Agents\Schema\Schema;
201+
use Utopia\Agents\Schema\SchemaObject;
202+
203+
$object = new SchemaObject();
204+
$object->addProperty('location', [
205+
'type' => SchemaObject::TYPE_STRING,
206+
'description' => 'The city and state, e.g. San Francisco, CA',
207+
]);
208+
209+
$schema = new Schema(
210+
name: 'get_weather',
211+
description: 'Get the current weather in a given location in well structured JSON',
212+
object: $object,
213+
required: $object->getNames()
214+
);
215+
216+
$agent->setSchema($schema);
217+
```
218+
195219
## Tests
196220

197221
To run all unit tests, use the following Docker command:

src/Agents/Adapter.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ abstract public function getModel(): string;
8888
*/
8989
abstract public function setModel(string $model): self;
9090

91+
/**
92+
* Check if the model supports JSON schema
93+
*
94+
* @return bool
95+
*/
96+
abstract public function isSchemaSupported(): bool;
97+
9198
/**
9299
* Get the current agent
93100
*

src/Agents/Adapters/Anthropic.php

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Utopia\Agents\Adapter;
66
use Utopia\Agents\Message;
77
use Utopia\Agents\Messages\Text;
8+
use Utopia\Agents\Schema;
89
use Utopia\Fetch\Chunk;
910
use Utopia\Fetch\Client;
1011

@@ -85,6 +86,16 @@ public function __construct(
8586
$this->setModel($model);
8687
}
8788

89+
/**
90+
* Check if the model supports JSON schema
91+
*
92+
* @return bool
93+
*/
94+
public function isSchemaSupported(): bool
95+
{
96+
return true;
97+
}
98+
8899
/**
89100
* Send a message to the Anthropic API
90101
*
@@ -143,25 +154,54 @@ public function send(array $messages, ?callable $listener = null): Message
143154
];
144155
}
145156

157+
$schema = $this->getAgent()->getSchema();
146158
$payload = [
147159
'model' => $this->model,
148160
'system' => $systemMessages,
149161
'messages' => $formattedMessages,
150162
'max_tokens' => $this->maxTokens,
151163
'temperature' => $this->temperature,
152-
'stream' => true,
153164
];
154165

166+
if (isset($schema)) {
167+
$payload['tools'] = [
168+
[
169+
'name' => $schema->getName(),
170+
'description' => $schema->getDescription(),
171+
'input_schema' => [
172+
'type' => 'object',
173+
'properties' => $schema->getProperties(),
174+
'required' => $schema->getRequired(),
175+
],
176+
],
177+
];
178+
$payload['tool_choice'] = [
179+
'type' => 'tool',
180+
'name' => $schema->getName(),
181+
];
182+
$payload['stream'] = false;
183+
} else {
184+
$payload['stream'] = true;
185+
}
186+
155187
$content = '';
156-
$response = $client->fetch(
157-
'https://api.anthropic.com/v1/messages',
158-
Client::METHOD_POST,
159-
$payload,
160-
[],
161-
function ($chunk) use (&$content, $listener) {
162-
$content .= $this->process($chunk, $listener);
163-
}
164-
);
188+
if ($payload['stream']) {
189+
$response = $client->fetch(
190+
'https://api.anthropic.com/v1/messages',
191+
Client::METHOD_POST,
192+
$payload,
193+
[],
194+
function ($chunk) use (&$content, $listener) {
195+
$content .= $this->process($chunk, $listener);
196+
}
197+
);
198+
} else {
199+
$response = $client->fetch(
200+
'https://api.anthropic.com/v1/messages',
201+
Client::METHOD_POST,
202+
$payload,
203+
);
204+
}
165205

166206
if ($response->getStatusCode() >= 400) {
167207
throw new \Exception(
@@ -170,7 +210,35 @@ function ($chunk) use (&$content, $listener) {
170210
);
171211
}
172212

173-
return new Text($content);
213+
if ($payload['stream']) {
214+
return new Text($content);
215+
}
216+
217+
$body = $response->getBody();
218+
$json = is_string($body) ? json_decode($body, true) : null;
219+
220+
$text = '';
221+
if (is_array($json) && $schema !== null) {
222+
$content = $json['content'] ?? null;
223+
if (is_array($content) && isset($content[0])) {
224+
$item = $content[0];
225+
if (is_array($item) &&
226+
isset($item['type']) && $item['type'] === 'tool_use' &&
227+
isset($item['name']) && $item['name'] === $schema->getName()) {
228+
$text = $item['input'];
229+
}
230+
}
231+
}
232+
233+
if ($text === '') {
234+
$text = is_string($body) ? $body : (is_array($json) ? json_encode($json) : '');
235+
}
236+
237+
if (is_array($text)) {
238+
$text = json_encode($text);
239+
}
240+
241+
return new Text($text);
174242
}
175243

176244
/**

src/Agents/Adapters/Deepseek.php

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ public function __construct(
7070
$this->setModel($model);
7171
}
7272

73+
/**
74+
* Check if the model supports JSON schema
75+
*
76+
* @return bool
77+
*/
78+
public function isSchemaSupported(): bool
79+
{
80+
return true;
81+
}
82+
7383
/**
7484
* Send a message to the Deepseek API
7585
*
@@ -109,6 +119,11 @@ public function send(array $messages, ?callable $listener = null): Message
109119
$systemMessage = $this->getAgent()->getDescription().
110120
(empty($instructions) ? '' : "\n\n".implode("\n\n", $instructions));
111121

122+
$schema = $this->getAgent()->getSchema();
123+
if ($schema !== null) {
124+
$systemMessage .= "\n\n"."USE THE JSON SCHEMA BELOW TO GENERATE A VALID JSON RESPONSE: \n".$schema->toJson();
125+
}
126+
112127
if (! empty($systemMessage)) {
113128
array_unshift($formattedMessages, [
114129
'role' => 'system',
@@ -124,6 +139,12 @@ public function send(array $messages, ?callable $listener = null): Message
124139
'stream' => true,
125140
];
126141

142+
if ($schema !== null) {
143+
$payload['response_format'] = [
144+
'type' => 'json_object',
145+
];
146+
}
147+
127148
$content = '';
128149
$response = $client->fetch(
129150
'https://api.deepseek.com/chat/completions',
@@ -142,9 +163,7 @@ function ($chunk) use (&$content, $listener) {
142163
);
143164
}
144165

145-
$message = new Text($content);
146-
147-
return $message;
166+
return new Text($content);
148167
}
149168

150169
/**

src/Agents/Adapters/Gemini.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,16 @@ public function __construct(
9393
$this->setModel($model);
9494
}
9595

96+
/**
97+
* Check if the model supports JSON schema
98+
*
99+
* @return bool
100+
*/
101+
public function isSchemaSupported(): bool
102+
{
103+
return false;
104+
}
105+
96106
/**
97107
* Send a message to the API
98108
*

src/Agents/Adapters/OpenAI.php

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Utopia\Agents\Adapter;
66
use Utopia\Agents\Message;
77
use Utopia\Agents\Messages\Text;
8+
use Utopia\Agents\Schema;
89
use Utopia\Fetch\Chunk;
910
use Utopia\Fetch\Client;
1011

@@ -103,6 +104,16 @@ public function __construct(
103104
$this->setModel($model);
104105
}
105106

107+
/**
108+
* Check if the model supports JSON schema
109+
*
110+
* @return bool
111+
*/
112+
public function isSchemaSupported(): bool
113+
{
114+
return true;
115+
}
116+
106117
/**
107118
* Send a message to the API
108119
*
@@ -154,9 +165,28 @@ public function send(array $messages, ?callable $listener = null): Message
154165
'model' => $this->model,
155166
'messages' => $formattedMessages,
156167
'temperature' => $this->temperature,
157-
'stream' => true,
158168
];
159169

170+
$schema = $this->getAgent()->getSchema();
171+
if ($schema !== null) {
172+
$payload['response_format'] = [
173+
'type' => 'json_schema',
174+
'json_schema' => [
175+
'name' => $schema->getName(),
176+
'strict' => true,
177+
'schema' => [
178+
'type' => 'object',
179+
'properties' => $schema->getProperties(),
180+
'required' => $schema->getRequired(),
181+
'additionalProperties' => false,
182+
],
183+
],
184+
];
185+
$payload['stream'] = false;
186+
} else {
187+
$payload['stream'] = true;
188+
}
189+
160190
// Use 'max_completion_tokens' for o-series models, else 'max_tokens'
161191
$oSeriesModels = [
162192
self::MODEL_O3,
@@ -170,26 +200,40 @@ public function send(array $messages, ?callable $listener = null): Message
170200
}
171201

172202
$content = '';
173-
$response = $client->fetch(
174-
$this->endpoint,
175-
Client::METHOD_POST,
176-
$payload,
177-
[],
178-
function ($chunk) use (&$content, $listener) {
179-
$content .= $this->process($chunk, $listener);
180-
}
181-
);
182203

183-
if ($response->getStatusCode() >= 400) {
184-
throw new \Exception(
185-
ucfirst($this->getName()).' API error: '.$content,
186-
$response->getStatusCode()
204+
if ($payload['stream']) {
205+
$response = $client->fetch(
206+
$this->endpoint,
207+
Client::METHOD_POST,
208+
$payload,
209+
[],
210+
function ($chunk) use (&$content, $listener) {
211+
$content .= $this->process($chunk, $listener);
212+
}
187213
);
188-
}
189214

190-
$message = new Text($content);
215+
if ($response->getStatusCode() >= 400) {
216+
throw new \Exception(
217+
ucfirst($this->getName()).' API error: '.$content,
218+
$response->getStatusCode()
219+
);
220+
}
221+
} else {
222+
$response = $client->fetch(
223+
$this->endpoint,
224+
Client::METHOD_POST,
225+
$payload,
226+
);
227+
$body = $response->getBody();
228+
$json = is_string($body) ? json_decode($body, true) : null;
229+
if (is_array($json) && isset($json['choices'][0]['message']['content'])) {
230+
$content = $json['choices'][0]['message']['content'];
231+
} else {
232+
throw new \Exception('Invalid response format received from the API');
233+
}
234+
}
191235

192-
return $message;
236+
return new Text($content);
193237
}
194238

195239
/**

src/Agents/Adapters/Perplexity.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ public function __construct(
6666
);
6767
}
6868

69+
/**
70+
* Check if the model supports JSON schema
71+
*
72+
* @return bool
73+
*/
74+
public function isSchemaSupported(): bool
75+
{
76+
return false;
77+
}
78+
6979
/**
7080
* Get available models
7181
*

src/Agents/Adapters/XAI.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ public function __construct(
4949
);
5050
}
5151

52+
/**
53+
* Check if the model supports JSON schema
54+
*
55+
* @return bool
56+
*/
57+
public function isSchemaSupported(): bool
58+
{
59+
return false;
60+
}
61+
5262
/**
5363
* Get available models
5464
*

0 commit comments

Comments
 (0)