diff --git a/README.md b/README.md index 9e4f850..d7fd11d 100755 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ OpenAI APIs for XP This library implements OpenAI APIs with a low-level abstraction approach, supporting their REST and realtime APIs, request and response streaming, function calling and TikToken encoding. -Completions +Quick start ----------- Using the REST API, see https://platform.openai.com/docs/api-reference/making-requests @@ -19,12 +19,11 @@ use com\openai\rest\OpenAIEndpoint; use util\cmd\Console; $ai= new OpenAIEndpoint('https://'.getenv('OPENAI_API_KEY').'@api.openai.com/v1'); -$payload= [ - 'model' => 'gpt-4o-mini', - 'messages' => [['role' => 'user', 'content' => $prompt]], -]; -Console::writeLine($ai->api('/chat/completions')->invoke($payload)); +Console::writeLine($ai->api('/responses')->invoke([ + 'model' => 'gpt-4o-mini', + 'input' => $prompt, +])); ``` Streaming @@ -36,19 +35,18 @@ use com\openai\rest\OpenAIEndpoint; use util\cmd\Console; $ai= new OpenAIEndpoint('https://'.getenv('OPENAI_API_KEY').'@api.openai.com/v1'); -$payload= [ - 'model' => 'gpt-4o-mini', - 'messages' => [['role' => 'user', 'content' => $prompt]], -]; -$stream= $ai->api('/chat/completions')->stream($payload); -foreach ($stream->deltas('content') as $delta) { - Console::write($delta); +$events= $ai->api('/responses')->stream([ + 'model' => 'gpt-4o-mini', + 'input' => $prompt, +]); +foreach ($events as $type => $value) { + Console::write('<', $type, '> ', $value); } Console::writeLine(); ``` -To access the result object after streaming, use `$stream->result()`. It contains the choices list as well as model, filter results and usage information. +To access the result object, check for the *response.completed* event type and use its value. It contains the outuputs as well as model, filter results and usage information. TikToken -------- @@ -197,9 +195,9 @@ $functions= (new Functions())->register('weather', new Weather()); $ai= new OpenAIEndpoint('https://'.getenv('OPENAI_API_KEY').'@api.openai.com/v1'); $payload= [ - 'model' => 'gpt-4o-mini', - 'tools' => new Tools($functions), - 'messages' => [['role' => 'user', 'content' => $prompt]], + 'model' => 'gpt-4o-mini', + 'tools' => new Tools($functions), + 'input' => [['type' => 'message', 'role' => 'user', 'content' => $content]], ]; ``` @@ -213,23 +211,24 @@ use util\cmd\Console; // ...setup code from above... $calls= $functions->calls()->catching(fn($t) => $t->printStackTrace()); -complete: $result= $ai->api('/chat/completions')->invoke($payload)); - -// If tool calls are requested, invoke them and return to next completion cycle -if ('tool_calls' === ($result['choices'][0]['finish_reason'] ?? null)) { - $payload['messages'][]= $result['choices'][0]['message']; - - foreach ($result['choices'][0]['message']['tool_calls'] as $call) { - $return= $calls->call($call['function']['name'], $call['function']['arguments']); - $payload['messages'][]= [ - 'role' => 'tool', - 'tool_call_id' => $call['id'], - 'content' => $return, - ]; - } - - goto complete; +next: $result= $ai->api('/responses')->invoke($payload)); + +// If function calls are requested, invoke them and return to next response cycle +$invokations= false; +foreach ($result['output'] as $output) { + if ('function_call' !== $output['type']) continue; + + $invokations= true; + $return= $calls->call($call['name'], $call['arguments']); + + $payload['input'][]= $call; + $payload['input'][]= [ + 'type' => 'function_call_output', + 'call_id' => $call['call_id'], + 'output' => $return, + ]; } +if ($invokations) goto next; // Print out final result Console::writeLine($result); @@ -256,7 +255,7 @@ class Memory { // ...shortened for brevity... $context= ['user' => $user]; -$return= $calls->call($call['function']['name'], $call['function']['arguments'], $context); +$return= $calls->call($call['name'], $call['arguments'], $context); ``` Azure OpenAI @@ -271,12 +270,11 @@ $ai= new AzureAIEndpoint( 'https://'.getenv('AZUREAI_API_KEY').'@example.openai.azure.com/openai/deployments/mini', '2024-02-01' ); -$payload= [ - 'model' => 'gpt-4o-mini', - 'messages' => [['role' => 'user', 'content' => $prompt]], -]; -Console::writeLine($ai->api('/chat/completions')->invoke($payload)); +Console::writeLine($ai->api('/responses')->invoke([ + 'model' => 'gpt-4o-mini', + 'input' => $prompt, +])); ``` Distributing requests @@ -293,12 +291,11 @@ $endpoints= [ ]; $ai= new Distributed($endpoints, new ByRemainingRequests()); -$payload= [ - 'model' => 'gpt-4o-mini', - 'messages' => [['role' => 'user', 'content' => $prompt]], -]; -Console::writeLine($ai->api('/chat/completions')->invoke($payload)); +Console::writeLine($ai->api('/responses')->invoke([ + 'model' => 'gpt-4o-mini', + 'input' => $prompt, +])); foreach ($endpoints as $i => $endpoint) { Console::writeLine('Endpoint #', $i, ': ', $endpoint->rateLimit()); } @@ -354,6 +351,29 @@ $api= new RealtimeApi('wss://example.openai.azure.com/openai/realtime?'. $session= $api->connect(['api-key' => getenv('AZUREAI_API_KEY')]); ``` +Completions API +--------------- +To use the legacy (but industry standard) chat completions API: + +```php +use com\openai\rest\OpenAIEndpoint; +use util\cmd\Console; + +$ai= new OpenAIEndpoint('https://'.getenv('OPENAI_API_KEY').'@api.openai.com/v1'); + +$flow= $ai->api('/chat/completions')->flow([ + 'model' => 'gpt-4o-mini', + 'messages' => [['role' => 'user', 'content' => $prompt]], +]); +$flow= $ai->api('/chat/completions')->flow($payload); +foreach ($flow->deltas() as $type => $delta) { + Console::writeLine('<', $type, '> ', $delta); +} +Console::writeLine(); +``` + +The result object is computed from the streamed deltas and can be retrieved by accessing *$flow->result()*. + See also -------- * https://github.com/openai/tiktoken/ diff --git a/src/main/php/com/openai/rest/Api.class.php b/src/main/php/com/openai/rest/Api.class.php index cd3d88d..5bffcd2 100644 --- a/src/main/php/com/openai/rest/Api.class.php +++ b/src/main/php/com/openai/rest/Api.class.php @@ -1,17 +1,45 @@ true, 'stream_options' => ['include_usage' => true]]; + const EVENTS= 'text/event-stream'; private $resource, $rateLimit; + private $adapt= null; /** Creates a new API instance from a given REST resource */ public function __construct(RestResource $resource, RateLimit $rateLimit) { $this->resource= $resource; $this->rateLimit= $rateLimit; + + // In the legacy completions API, streaming requires an option to include usage and tools + // are formatted in a substructure, see https://github.com/xp-forge/openai/issues/20 + if (0 === substr_compare($resource->uri()->path(), '/completions', -12)) { + $structure= function($tools) { + foreach ($tools->selection as $select) { + if ($select instanceof Functions) { + foreach ($select->schema() as $name => $function) { + yield ['type' => 'function', 'function' => [ + 'name' => $name, + 'description' => $function['description'], + 'parameters' => $function['input'], + ]]; + } + } else { + yield $select; + } + } + }; + $this->adapt= function($payload) use($structure) { + if ($payload['stream'] ?? null) $payload['stream_options']= ['include_usage' => true]; + if ($payload['tools'] ?? null) $payload['tools']= [...$structure($payload['tools'])]; + return $payload; + }; + } } /** @@ -23,7 +51,7 @@ public function __construct(RestResource $resource, RateLimit $rateLimit) { * @throws webservices.rest.UnexpectedStatus */ public function transmit($payload, $mime= self::JSON): RestResponse { - $r= $this->resource->post($payload, $mime); + $r= $this->resource->post($this->adapt ? ($this->adapt)($payload) : $payload, $mime); $this->rateLimit->update($r->header('x-ratelimit-remaining-requests')); if (200 === $r->status()) return $r; @@ -50,9 +78,15 @@ public function invoke(array $payload) { return $this->transmit($payload)->value(); } - /** Streams API response */ - public function stream(array $payload): EventStream { - $this->resource->accepting('text/event-stream'); - return new EventStream($this->transmit(self::STREAMING + $payload)->stream()); + /** Yields events from a streamed response */ + public function stream(array $payload): Events { + $this->resource->accepting(self::EVENTS); + return new Events($this->transmit(['stream' => true] + $payload)->stream()); + } + + /** Completions API flow: Yield deltas and compute result */ + public function flow(array $payload): Flow { + $this->resource->accepting(self::EVENTS); + return new Flow($this->transmit(['stream' => true] + $payload)->stream()); } } \ No newline at end of file diff --git a/src/main/php/com/openai/rest/Events.class.php b/src/main/php/com/openai/rest/Events.class.php new file mode 100755 index 0000000..45a89f1 --- /dev/null +++ b/src/main/php/com/openai/rest/Events.class.php @@ -0,0 +1,41 @@ +stream= $stream; + } + + /** Returns events while reading */ + public function getIterator(): Traversable { + $r= new StringReader($this->stream); + $event= null; + + // Read all lines starting with `event` or `data`, ignore others + while (null !== ($line= $r->readLine())) { + // echo "\n<<< $line\n"; + if (0 === strncmp($line, 'event: ', 6)) { + $event= substr($line, 7); + } else if (0 === strncmp($line, 'data: ', 5)) { + $data= substr($line, 6); + if (self::DONE !== $data) yield $event => json_decode($data, true); + $event= null; + } + } + } +} \ No newline at end of file diff --git a/src/main/php/com/openai/rest/EventStream.class.php b/src/main/php/com/openai/rest/Flow.class.php similarity index 97% rename from src/main/php/com/openai/rest/EventStream.class.php rename to src/main/php/com/openai/rest/Flow.class.php index c08814c..ce7e954 100644 --- a/src/main/php/com/openai/rest/EventStream.class.php +++ b/src/main/php/com/openai/rest/Flow.class.php @@ -5,7 +5,7 @@ use util\Objects; /** - * OpenAI API event stream + * OpenAI API completion flow * * Note: While these event streams are based on server-sent events, they do not * utilize their full extent - there are no event types, IDs or multiline data. @@ -13,9 +13,9 @@ * * @see https://platform.openai.com/docs/guides/production-best-practices/streaming * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events - * @test com.openai.unittest.EventStreamTest + * @test com.openai.unittest.FlowTest */ -class EventStream { +class Flow { private $stream; private $result= null; diff --git a/src/main/php/com/openai/rest/RestEndpoint.class.php b/src/main/php/com/openai/rest/RestEndpoint.class.php index a555fe5..0cc93c0 100755 --- a/src/main/php/com/openai/rest/RestEndpoint.class.php +++ b/src/main/php/com/openai/rest/RestEndpoint.class.php @@ -13,11 +13,12 @@ public function __construct($endpoint) { foreach ($tools->selection as $select) { if ($select instanceof Functions) { foreach ($select->schema() as $name => $function) { - yield ['type' => 'function', 'function' => [ + yield [ + 'type' => 'function', 'name' => $name, 'description' => $function['description'], 'parameters' => $function['input'], - ]]; + ]; } } else { yield $select; diff --git a/src/test/php/com/openai/unittest/ApiEndpointTest.class.php b/src/test/php/com/openai/unittest/ApiEndpointTest.class.php index 7e6180c..cf8df8d 100644 --- a/src/test/php/com/openai/unittest/ApiEndpointTest.class.php +++ b/src/test/php/com/openai/unittest/ApiEndpointTest.class.php @@ -15,6 +15,29 @@ private function testingEndpoint(): TestEndpoint { 'POST /audio/transcriptions' => function($call) { return $call->respond(200, 'OK', ['Content-Type' => 'application/json'], '"Test"'); }, + 'POST /responses' => function($call) { + if ($call->request()->payload()->value()['stream'] ?? false) { + $headers= ['Content-Type' => 'text/event-stream']; + $payload= implode("\n", [ + 'event: response.created', + 'data: {"response":{"id":"test"}}', + '', + 'event: response.output_item.added', + 'data: {"type":"message"}', + '', + 'event: response.output_text.delta', + 'data: {"delta":"Test"}', + '', + 'event: response.completed', + 'data: {"response":{"id":"test"}}', + ]); + } else { + $headers= ['Content-Type' => 'application/json']; + $payload= '{"output":[{"type":"message","role":"assistant","content":[]}]}'; + } + + return $call->respond(200, 'OK', $headers, $payload); + }, 'POST /chat/completions' => function($call) { if ($call->request()->payload()->value()['stream'] ?? false) { $headers= ['Content-Type' => 'text/event-stream']; @@ -29,12 +52,35 @@ private function testingEndpoint(): TestEndpoint { } return $call->respond(200, 'OK', $headers, $payload); - } + }, ]); } #[Test] public function invoke() { + $endpoint= $this->fixture($this->testingEndpoint()); + Assert::equals( + ['output' => [['type' => 'message', 'role' => 'assistant', 'content' => []]]], + $endpoint->api('/responses')->invoke(['stream' => false]) + ); + } + + #[Test] + public function stream() { + $endpoint= $this->fixture($this->testingEndpoint()); + Assert::equals( + [ + 'response.created' => ['response' => ['id' => 'test']], + 'response.output_item.added' => ['type' => 'message'], + 'response.output_text.delta' => ['delta' => 'Test'], + 'response.completed' => ['response' => ['id' => 'test']], + ], + iterator_to_array($endpoint->api('/responses')->stream(['stream' => true])) + ); + } + + #[Test] + public function invoke_completions() { $endpoint= $this->fixture($this->testingEndpoint()); Assert::equals( ['choices' => [['message' => ['role' => 'assistant', 'content' => 'Test']]]], @@ -43,11 +89,11 @@ public function invoke() { } #[Test] - public function stream() { + public function flow_completions() { $endpoint= $this->fixture($this->testingEndpoint()); Assert::equals( ['choices' => [['message' => ['role' => 'assistant', 'content' => 'Test']]]], - $endpoint->api('/chat/completions')->stream(['stream' => true])->result() + $endpoint->api('/chat/completions')->flow(['stream' => true])->result() ); } diff --git a/src/test/php/com/openai/unittest/EventsTest.class.php b/src/test/php/com/openai/unittest/EventsTest.class.php new file mode 100755 index 0000000..cc7cd29 --- /dev/null +++ b/src/test/php/com/openai/unittest/EventsTest.class.php @@ -0,0 +1,66 @@ +input([])); + } + + #[Test] + public function empty_input() { + Assert::equals([], $this->pairsOf(new Events($this->input([])))); + } + + #[Test] + public function response_with_text_delta() { + Assert::equals( + [ + ['response.created' => [ + 'type' => 'response.created', + 'response' => ['id' => 'test'], + ]], + ['response.output_item.added' => [ + 'type' => 'response.output_item.added', + 'output_index' => 0, + 'item' => ['type' => 'message'], + ]], + ['response.output_text.delta' => [ + 'type' => 'response.output_text.delta', + 'output_index' => 0, + 'content_index' => 0, + 'delta' => 'Test', + ]], + ['response.output_text.delta' => [ + 'type' => 'response.output_text.delta', + 'content_index' => 0, + 'output_index' => 0, + 'delta' => 'ed', + ]], + ['response.completed' => [ + 'type' => 'response.completed', + 'response' => ['id' => 'test'], + ]], + ], + $this->pairsOf(new Events($this->input($this->contentResponse()))) + ); + } + + #[Test] + public function can_be_used_for_completions() { + Assert::equals( + [ + [null => ['choices' => [['delta' => ['role' => 'assistant']]]]], + [null => ['choices' => [['delta' => ['content' => 'Test']]]]], + [null => ['choices' => [['delta' => ['content' => 'ed']]]]], + ], + $this->pairsOf(new Events($this->input($this->contentCompletions()))) + ); + } +} \ No newline at end of file diff --git a/src/test/php/com/openai/unittest/EventStreamTest.class.php b/src/test/php/com/openai/unittest/FlowTest.class.php similarity index 50% rename from src/test/php/com/openai/unittest/EventStreamTest.class.php rename to src/test/php/com/openai/unittest/FlowTest.class.php index 923a28d..bd6c9cc 100644 --- a/src/test/php/com/openai/unittest/EventStreamTest.class.php +++ b/src/test/php/com/openai/unittest/FlowTest.class.php @@ -1,47 +1,11 @@ $delta) { - $r[]= [$field => $delta]; - } - return $r; - } +class FlowTest { + use Streams; /** Filtered deltas */ private function filtered(): iterable { @@ -52,32 +16,32 @@ private function filtered(): iterable { #[Test] public function can_create() { - new EventStream($this->input([])); + new Flow($this->input([])); } #[Test] public function receive_done_as_first_token() { $events= ['data: [DONE]']; - Assert::equals([], $this->pairsOf((new EventStream($this->input($events)))->deltas())); + Assert::equals([], $this->pairsOf((new Flow($this->input($events)))->deltas())); } #[Test] public function does_not_continue_reading_after_done() { $events= ['data: [DONE]', '', 'data: "Test"']; - Assert::equals([], $this->pairsOf((new EventStream($this->input($events)))->deltas())); + Assert::equals([], $this->pairsOf((new Flow($this->input($events)))->deltas())); } #[Test] public function deltas() { Assert::equals( [['role' => 'assistant'], ['content' => 'Test'], ['content' => 'ed']], - $this->pairsOf((new EventStream($this->input($this->contentStream())))->deltas()) + $this->pairsOf((new Flow($this->input($this->contentCompletions())))->deltas()) ); } #[Test] public function deltas_throws_if_already_consumed() { - $events= new EventStream($this->input($this->contentStream())); + $events= new Flow($this->input($this->contentCompletions())); iterator_count($events->deltas()); Assert::throws(IllegalStateException::class, fn() => iterator_count($events->deltas())); @@ -87,7 +51,7 @@ public function deltas_throws_if_already_consumed() { public function ignores_newlines() { Assert::equals( [['role' => 'assistant'], ['content' => 'Test'], ['content' => 'ed']], - $this->pairsOf((new EventStream($this->input(['', ...$this->contentStream()])))->deltas()) + $this->pairsOf((new Flow($this->input(['', ...$this->contentCompletions()])))->deltas()) ); } @@ -95,7 +59,7 @@ public function ignores_newlines() { public function filtered_deltas($filter, $expected) { Assert::equals( $expected, - $this->pairsOf((new EventStream($this->input($this->contentStream())))->deltas($filter)) + $this->pairsOf((new Flow($this->input($this->contentCompletions())))->deltas($filter)) ); } @@ -103,7 +67,7 @@ public function filtered_deltas($filter, $expected) { public function result() { Assert::equals( ['choices' => [['message' => ['role' => 'assistant', 'content' => 'Tested']]]], - (new EventStream($this->input($this->contentStream())))->result() + (new Flow($this->input($this->contentCompletions())))->result() ); } @@ -116,7 +80,7 @@ public function tool_call_deltas() { ['tool_calls' => [['function' => ['arguments' => '{']]]], ['tool_calls' => [['function' => ['arguments' => '}']]]], ], - $this->pairsOf((new EventStream($this->input($this->toolCallStream())))->deltas()) + $this->pairsOf((new Flow($this->input($this->toolCallCompletions())))->deltas()) ); } @@ -128,7 +92,7 @@ public function tool_call_result() { 'message' => ['role' => 'assistant', 'tool_calls' => $calls], 'finish_reason' => 'function_call', ]]], - (new EventStream($this->input($this->toolCallStream())))->result() + (new Flow($this->input($this->toolCallCompletions())))->result() ); } } \ No newline at end of file diff --git a/src/test/php/com/openai/unittest/Streams.class.php b/src/test/php/com/openai/unittest/Streams.class.php new file mode 100755 index 0000000..74c1ec7 --- /dev/null +++ b/src/test/php/com/openai/unittest/Streams.class.php @@ -0,0 +1,62 @@ + $value) { + $r[]= [$key => $value]; + } + return $r; + } +} \ No newline at end of file diff --git a/src/test/php/com/openai/unittest/ToolsTest.class.php b/src/test/php/com/openai/unittest/ToolsTest.class.php index 52b31c2..391dc2c 100644 --- a/src/test/php/com/openai/unittest/ToolsTest.class.php +++ b/src/test/php/com/openai/unittest/ToolsTest.class.php @@ -11,9 +11,12 @@ class ToolsTest { /** Returns a testing API endpoint */ private function testingEndpoint(): TestEndpoint { return new TestEndpoint([ - 'POST /echo' => function($call) { + 'POST /completions' => function($call) { return $call->respond(200, 'OK', ['Content-Type' => 'application/json'], $call->content()); - } + }, + 'POST /responses' => function($call) { + return $call->respond(200, 'OK', ['Content-Type' => 'application/json'], $call->content()); + }, ]); } @@ -41,14 +44,14 @@ public function with_custom_functions() { } #[Test] - public function serialized_for_rest_api() { + public function serialized_for_completions_api() { $functions= $this->functions(); $endpoint= new OpenAIEndpoint($this->testingEndpoint()); - $result= $endpoint->api('/echo')->invoke(['tools' => new Tools($functions)]); + $result= $endpoint->api('/completions')->invoke(['tools' => new Tools($functions)]); Assert::equals( ['tools' => [[ - 'type' => 'function', + 'type' => 'function', 'function' => [ 'name' => 'greet_world', 'description' => 'World', @@ -59,6 +62,23 @@ public function serialized_for_rest_api() { ); } + #[Test] + public function serialized_for_responses_api() { + $functions= $this->functions(); + $endpoint= new OpenAIEndpoint($this->testingEndpoint()); + $result= $endpoint->api('/responses')->invoke(['tools' => new Tools($functions)]); + + Assert::equals( + ['tools' => [[ + 'type' => 'function', + 'name' => 'greet_world', + 'description' => 'World', + 'parameters' => $functions->schema()->current()['input'], + ]]], + $result + ); + } + #[Test] public function serialized_for_realtime_api() { $functions= $this->functions();