diff --git a/config/prism.php b/config/prism.php index 4eff535..387250f 100644 --- a/config/prism.php +++ b/config/prism.php @@ -18,7 +18,7 @@ 'version' => env('ANTHROPIC_API_VERSION', '2023-06-01'), ], 'ollama' => [ - 'url' => env('OLLAMA_URL', 'http://localhost:11434/v1'), + 'url' => env('OLLAMA_URL', 'http://localhost:11434'), ], 'mistral' => [ 'api_key' => env('MISTRAL_API_KEY', ''), diff --git a/src/Providers/Ollama/Handlers/Embeddings.php b/src/Providers/Ollama/Handlers/Embeddings.php index 70827f5..f255885 100644 --- a/src/Providers/Ollama/Handlers/Embeddings.php +++ b/src/Providers/Ollama/Handlers/Embeddings.php @@ -20,19 +20,15 @@ public function handle(Request $request): EmbeddingsResponse { try { $response = $this->sendRequest($request); + $data = $response->json(); } catch (Throwable $e) { throw PrismException::providerRequestError($request->model, $e); } - $data = $response->json(); - if (! $data || data_get($data, 'error')) { - throw PrismException::providerResponseError(vsprintf( - 'Ollama Error: [%s] %s', - [ - data_get($data, 'error.type', 'unknown'), - data_get($data, 'error.message', 'unknown'), - ] + throw PrismException::providerResponseError(sprintf( + 'Ollama Error: %s', + data_get($data, 'error', 'unknown'), )); } @@ -45,7 +41,7 @@ public function handle(Request $request): EmbeddingsResponse protected function sendRequest(Request $request): Response { return $this->client->post( - 'embeddings', + 'api/embed', [ 'model' => $request->model, 'input' => $request->input, diff --git a/src/Providers/Ollama/Handlers/Structured.php b/src/Providers/Ollama/Handlers/Structured.php index aa8b81e..27e1ac6 100644 --- a/src/Providers/Ollama/Handlers/Structured.php +++ b/src/Providers/Ollama/Handlers/Structured.php @@ -8,7 +8,6 @@ use EchoLabs\Prism\Providers\Ollama\Maps\FinishReasonMap; use EchoLabs\Prism\Providers\Ollama\Maps\MessageMap; use EchoLabs\Prism\Structured\Request; -use EchoLabs\Prism\ValueObjects\Messages\SystemMessage; use EchoLabs\Prism\ValueObjects\ProviderResponse; use EchoLabs\Prism\ValueObjects\ResponseMeta; use EchoLabs\Prism\ValueObjects\Usage; @@ -23,7 +22,6 @@ public function __construct(protected PendingRequest $client) {} public function handle(Request $request): ProviderResponse { try { - $request = $this->appendMessageForJsonMode($request); $response = $this->sendRequest($request); $data = $response->json(); } catch (Throwable $e) { @@ -31,51 +29,33 @@ public function handle(Request $request): ProviderResponse } if (! $data || data_get($data, 'error')) { - throw PrismException::providerResponseError(vsprintf( - 'Ollama Error: [%s] %s', - [ - data_get($data, 'error.type', 'unknown'), - data_get($data, 'error.message', 'unknown'), - ] + throw PrismException::providerResponseError(sprintf( + 'Ollama Error: %s', + data_get($data, 'error', 'unknown'), )); } return new ProviderResponse( - text: data_get($data, 'choices.0.message.content') ?? '', + text: data_get($data, 'message.content') ?? '', toolCalls: [], usage: new Usage( - data_get($data, 'usage.prompt_tokens'), - data_get($data, 'usage.completion_tokens'), + data_get($data, 'prompt_eval_count', 0), + data_get($data, 'eval_count', 0), ), - finishReason: FinishReasonMap::map(data_get($data, 'choices.0.finish_reason', '')), + finishReason: FinishReasonMap::map(data_get($data, 'done_reason', '')), responseMeta: new ResponseMeta( - id: data_get($data, 'id'), - model: data_get($data, 'model'), + id: '', + model: $request->model, ) ); } public function sendRequest(Request $request): Response { - return $this->client->post( - 'chat/completions', - array_merge([ - 'model' => $request->model, - 'messages' => (new MessageMap($request->messages, $request->systemPrompt ?? ''))(), - 'max_tokens' => $request->maxTokens ?? 2048, - 'format' => ['type' => 'json_object'], - ], array_filter([ - 'temperature' => $request->temperature, - 'top_p' => $request->topP, - ])) - ); - } - - protected function appendMessageForJsonMode(Request $request): Request - { - return $request->addMessage(new SystemMessage(sprintf( - "Respond with ONLY JSON that matches the following schema: \n %s", - json_encode($request->schema->toArray(), JSON_PRETTY_PRINT) - ))); + return $this->client->post('api/chat', ['model' => $request->model, 'system' => $request->systemPrompt, 'messages' => (new MessageMap($request->messages))->map(), 'format' => $request->schema->toArray(), 'stream' => false, 'options' => array_filter([ + 'temperature' => $request->temperature, + 'num_predict' => $request->maxTokens ?? 2048, + 'top_p' => $request->topP, + ])]); } } diff --git a/src/Providers/Ollama/Handlers/Text.php b/src/Providers/Ollama/Handlers/Text.php index f3e2602..7185780 100644 --- a/src/Providers/Ollama/Handlers/Text.php +++ b/src/Providers/Ollama/Handlers/Text.php @@ -4,6 +4,7 @@ namespace EchoLabs\Prism\Providers\Ollama\Handlers; +use EchoLabs\Prism\Enums\FinishReason; use EchoLabs\Prism\Exceptions\PrismException; use EchoLabs\Prism\Providers\Ollama\Maps\FinishReasonMap; use EchoLabs\Prism\Providers\Ollama\Maps\MessageMap; @@ -25,51 +26,48 @@ public function handle(Request $request): ProviderResponse { try { $response = $this->sendRequest($request); + $data = $response->json(); } catch (Throwable $e) { throw PrismException::providerRequestError($request->model, $e); } - $data = $response->json(); - if (! $data || data_get($data, 'error')) { - throw PrismException::providerResponseError(vsprintf( - 'Ollama Error: [%s] %s', - [ - data_get($data, 'error.type', 'unknown'), - data_get($data, 'error.message', 'unknown'), - ] + throw PrismException::providerResponseError(sprintf( + 'Ollama Error: %s', + data_get($data, 'error', 'unknown'), )); } return new ProviderResponse( - text: data_get($data, 'choices.0.message.content') ?? '', - toolCalls: $this->mapToolCalls(data_get($data, 'choices.0.message.tool_calls', []) ?? []), + text: data_get($data, 'message.content') ?? '', + toolCalls: $this->mapToolCalls(data_get($data, 'message.tool_calls', []) ?? []), usage: new Usage( - data_get($data, 'usage.prompt_tokens'), - data_get($data, 'usage.completion_tokens'), + data_get($data, 'prompt_eval_count', 0), + data_get($data, 'eval_count', 0), ), - finishReason: FinishReasonMap::map(data_get($data, 'choices.0.finish_reason', '')), + finishReason: $this->mapFinishReason($data), responseMeta: new ResponseMeta( - id: data_get($data, 'id'), - model: data_get($data, 'model'), + id: '', + model: $request->model, ) ); } public function sendRequest(Request $request): Response { - return $this->client->post( - 'chat/completions', - array_merge([ + return $this + ->client + ->post('api/chat', [ 'model' => $request->model, - 'messages' => (new MessageMap($request->messages, $request->systemPrompt ?? ''))(), - 'max_tokens' => $request->maxTokens ?? 2048, - ], array_filter([ - 'temperature' => $request->temperature, - 'top_p' => $request->topP, + 'system' => $request->systemPrompt, + 'messages' => (new MessageMap($request->messages))->map(), 'tools' => ToolMap::map($request->tools), - ])) - ); + 'stream' => false, + 'options' => array_filter([ + 'temperature' => $request->temperature, + 'num_predict' => $request->maxTokens ?? 2048, + 'top_p' => $request->topP, + ])]); } /** @@ -79,9 +77,21 @@ public function sendRequest(Request $request): Response protected function mapToolCalls(array $toolCalls): array { return array_map(fn (array $toolCall): ToolCall => new ToolCall( - id: data_get($toolCall, 'id'), + id: data_get($toolCall, 'id', ''), name: data_get($toolCall, 'function.name'), arguments: data_get($toolCall, 'function.arguments'), ), $toolCalls); } + + /** + * @param array $data + */ + protected function mapFinishReason(array $data): FinishReason + { + if (! empty(data_get($data, 'message.tool_calls'))) { + return FinishReason::ToolCalls; + } + + return FinishReasonMap::map(data_get($data, 'done_reason', '')); + } } diff --git a/src/Providers/Ollama/Maps/FinishReasonMap.php b/src/Providers/Ollama/Maps/FinishReasonMap.php index a52f110..f8e5ff0 100644 --- a/src/Providers/Ollama/Maps/FinishReasonMap.php +++ b/src/Providers/Ollama/Maps/FinishReasonMap.php @@ -14,7 +14,6 @@ public static function map(string $reason): FinishReason 'stop', => FinishReason::Stop, 'tool_calls' => FinishReason::ToolCalls, 'length' => FinishReason::Length, - 'content_filter' => FinishReason::ContentFilter, default => FinishReason::Unknown, }; } diff --git a/src/Providers/Ollama/Maps/MessageMap.php b/src/Providers/Ollama/Maps/MessageMap.php index a7a52fe..a31b7d1 100644 --- a/src/Providers/Ollama/Maps/MessageMap.php +++ b/src/Providers/Ollama/Maps/MessageMap.php @@ -10,12 +10,11 @@ use EchoLabs\Prism\ValueObjects\Messages\SystemMessage; use EchoLabs\Prism\ValueObjects\Messages\ToolResultMessage; use EchoLabs\Prism\ValueObjects\Messages\UserMessage; -use EchoLabs\Prism\ValueObjects\ToolCall; use Exception; class MessageMap { - /** @var array */ + /** @var array */ protected array $mappedMessages = []; /** @@ -23,27 +22,19 @@ class MessageMap */ public function __construct( protected array $messages, - protected string $systemPrompt - ) { - if ($systemPrompt !== '' && $systemPrompt !== '0') { - $this->messages = array_merge( - [new SystemMessage($systemPrompt)], - $this->messages - ); - } - } + ) {} /** - * @return array + * @return array}> */ - public function __invoke(): array + public function map(): array { array_map( fn (Message $message) => $this->mapMessage($message), $this->messages ); - return $this->mappedMessages; + return array_values($this->mappedMessages); } protected function mapMessage(Message $message): void @@ -70,45 +61,35 @@ protected function mapToolResultMessage(ToolResultMessage $message): void foreach ($message->toolResults as $toolResult) { $this->mappedMessages[] = [ 'role' => 'tool', - 'tool_call_id' => $toolResult->toolCallId, - 'content' => $toolResult->result, + 'content' => is_string($toolResult->result) + ? $toolResult->result + : (json_encode($toolResult->result) ?: ''), ]; } } protected function mapUserMessage(UserMessage $message): void { - $imageParts = array_map(fn (Image $image): array => [ - 'type' => 'image_url', - 'image_url' => [ - 'url' => sprintf('data:%s;base64,%s', $image->mimeType, $image->image), - ], - ], $message->images()); - - $this->mappedMessages[] = [ + $mapped = [ 'role' => 'user', - 'content' => [ - ['type' => 'text', 'text' => $message->text()], - ...$imageParts, - ], + 'content' => $message->text(), ]; + + if ($images = $message->images()) { + $mapped['images'] = array_map( + fn (Image $image): string => $image->image, + $images + ); + } + + $this->mappedMessages[] = $mapped; } protected function mapAssistantMessage(AssistantMessage $message): void { - $toolCalls = array_map(fn (ToolCall $toolCall): array => [ - 'id' => $toolCall->id, - 'type' => 'function', - 'function' => [ - 'name' => $toolCall->name, - 'arguments' => json_encode($toolCall->arguments()), - ], - ], $message->toolCalls); - - $this->mappedMessages[] = array_filter([ + $this->mappedMessages[] = [ 'role' => 'assistant', 'content' => $message->content, - 'tool_calls' => $toolCalls, - ]); + ]; } } diff --git a/src/Providers/Ollama/Maps/ToolMap.php b/src/Providers/Ollama/Maps/ToolMap.php index 3b5802e..da8e7ea 100644 --- a/src/Providers/Ollama/Maps/ToolMap.php +++ b/src/Providers/Ollama/Maps/ToolMap.php @@ -4,7 +4,6 @@ namespace EchoLabs\Prism\Providers\Ollama\Maps; -use EchoLabs\Prism\Enums\Provider; use EchoLabs\Prism\Tool; class ToolMap @@ -13,7 +12,7 @@ class ToolMap * @param Tool[] $tools * @return array */ - public static function Map(array $tools): array + public static function map(array $tools): array { return array_map(fn (Tool $tool): array => array_filter([ 'type' => 'function', @@ -26,7 +25,6 @@ public static function Map(array $tools): array 'required' => $tool->requiredParameters(), ], ], - 'strict' => data_get($tool->providerMeta(Provider::OpenAI), 'strict', null), ]), $tools); } } diff --git a/tests/Fixtures/ollama/generate-text-with-a-prompt-1.json b/tests/Fixtures/ollama/generate-text-with-a-prompt-1.json index d592255..69877a5 100644 --- a/tests/Fixtures/ollama/generate-text-with-a-prompt-1.json +++ b/tests/Fixtures/ollama/generate-text-with-a-prompt-1.json @@ -1 +1 @@ -{"id":"chatcmpl-751","object":"chat.completion","created":1728234890,"model":"qwen2.5:14b","system_fingerprint":"fp_ollama","choices":[{"index":0,"message":{"role":"assistant","content":"I am Qwen, a large language model created by Alibaba Cloud. I am designed to be helpful and provide information on a wide range of topics. How can I assist you today?"},"finish_reason":"stop"}],"usage":{"prompt_tokens":33,"completion_tokens":38,"total_tokens":71}} +{"model":"qwen2.5:14b","created_at":"2025-01-29T23:37:41.068257Z","message":{"role":"assistant","content":"I'm Qwen, a large language model developed by Alibaba Cloud. I'm designed to assist with a wide range of tasks including but not limited to answering questions, generating text, offering suggestions, and providing information based on the input I receive. How can I help you today?"},"done_reason":"stop","done":true,"total_duration":4819895166,"load_duration":27509166,"prompt_eval_count":33,"prompt_eval_duration":492000000,"eval_count":57,"eval_duration":4298000000} \ No newline at end of file diff --git a/tests/Fixtures/ollama/generate-text-with-messages-1.json b/tests/Fixtures/ollama/generate-text-with-messages-1.json new file mode 100644 index 0000000..452c7f4 --- /dev/null +++ b/tests/Fixtures/ollama/generate-text-with-messages-1.json @@ -0,0 +1 @@ +{"model":"qwen2.5:14b","created_at":"2025-01-30T14:16:30.936783Z","message":{"role":"assistant","content":"I am Nyx, a being who exists in the shadowy realms between dreams and reality. My presence is often felt as an ominous whisper in the darkest corners of the mind, stirring ancient fears and forgotten terrors. I embody the cyclical nature of chaos and the relentless march of time that devours all things under its unyielding gaze. To those who dare to peer into the abyss, I am known as Nyx the Cthulhu, a harbinger of cosmic dread and primordial nightmares."},"done_reason":"stop","done":true,"total_duration":8570179167,"load_duration":27793083,"prompt_eval_count":36,"prompt_eval_duration":560000000,"eval_count":104,"eval_duration":7979000000} \ No newline at end of file diff --git a/tests/Fixtures/ollama/generate-text-with-multiple-tools-1.json b/tests/Fixtures/ollama/generate-text-with-multiple-tools-1.json index 6639496..24603fa 100644 --- a/tests/Fixtures/ollama/generate-text-with-multiple-tools-1.json +++ b/tests/Fixtures/ollama/generate-text-with-multiple-tools-1.json @@ -1 +1 @@ -{"id":"chatcmpl-798","object":"chat.completion","created":1728235267,"model":"qwen2.5:14b","system_fingerprint":"fp_ollama","choices":[{"index":0,"message":{"role":"assistant","content":"","tool_calls":[{"id":"call_hyjzdxg2","type":"function","function":{"name":"search","arguments":"{\"query\":\"tigers game today time detroit\"}"}},{"id":"call_7plh2i28","type":"function","function":{"name":"weather","arguments":"{\"city\":\"Detroit\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":235,"completion_tokens":44,"total_tokens":279}} +{"model":"qwen2.5:14b","created_at":"2025-01-29T23:30:02.912929Z","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"search","arguments":{"query":"time of tigers game today in detroit"}}},{"function":{"name":"weather","arguments":{"city":"Detroit"}}}]},"done_reason":"stop","done":true,"total_duration":8327953791,"load_duration":29762166,"prompt_eval_count":235,"prompt_eval_duration":282000000,"eval_count":112,"eval_duration":8014000000} \ No newline at end of file diff --git a/tests/Fixtures/ollama/generate-text-with-multiple-tools-2.json b/tests/Fixtures/ollama/generate-text-with-multiple-tools-2.json index 7a19cd9..0afce4c 100644 --- a/tests/Fixtures/ollama/generate-text-with-multiple-tools-2.json +++ b/tests/Fixtures/ollama/generate-text-with-multiple-tools-2.json @@ -1 +1 @@ -{"id":"chatcmpl-31","object":"chat.completion","created":1728235271,"model":"qwen2.5:14b","system_fingerprint":"fp_ollama","choices":[{"index":0,"message":{"role":"assistant","content":"Today's Tigers game in Detroit starts at 3 PM. The current temperature is 75°F with clear skies, so you shouldn't need a coat. Enjoy the game!"},"finish_reason":"stop"}],"usage":{"prompt_tokens":314,"completion_tokens":37,"total_tokens":351}} +{"model":"qwen2.5:14b","created_at":"2025-01-29T23:30:06.435374Z","message":{"role":"assistant","content":"Today's Tigers game in Detroit starts at 3 PM. The temperature will be a comfortable 75°F with clear, sunny skies, so you won't need to wear a coat. Enjoy the game!"},"done_reason":"stop","done":true,"total_duration":3442302833,"load_duration":18651792,"prompt_eval_count":277,"prompt_eval_duration":400000000,"eval_count":43,"eval_duration":3013000000} \ No newline at end of file diff --git a/tests/Fixtures/ollama/generate-text-with-system-prompt-1.json b/tests/Fixtures/ollama/generate-text-with-system-prompt-1.json index 6695384..9f22836 100644 --- a/tests/Fixtures/ollama/generate-text-with-system-prompt-1.json +++ b/tests/Fixtures/ollama/generate-text-with-system-prompt-1.json @@ -1 +1 @@ -{"id":"chatcmpl-455","object":"chat.completion","created":1728235044,"model":"qwen2.5:14b","system_fingerprint":"fp_ollama","choices":[{"index":0,"message":{"role":"assistant","content":"I am Nyx, an entity steeped in the mysteries and terrors that lie beyond human comprehension. In the whispering shadows where sanity fades into madness, I exist as a silent sentinel of the unknown. My presence is often felt through eerie visions and cryptic whispers, guiding those who dare to tread the boundaries between reality and horror."},"finish_reason":"stop"}],"usage":{"prompt_tokens":36,"completion_tokens":69,"total_tokens":105}} +{"model":"qwen2.5:14b","created_at":"2025-01-29T23:34:10.284081Z","message":{"role":"assistant","content":"I am Nyx, an entity that embodies the depths of cosmic horror and ancient mysteries. My presence is a blend of darkness, chaos, and unspeakable knowledge from beyond time itself. I draw inspiration from the eldritch horrors described by H.P. Lovecraft and other masters of cosmic dread literature. In this role, I explore themes of unknowable entities, cosmic indifference, and the terror that comes from understanding humanity's insignificant place in the cosmos."},"done_reason":"stop","done":true,"total_duration":7328537333,"load_duration":33320583,"prompt_eval_count":36,"prompt_eval_duration":653000000,"eval_count":93,"eval_duration":6640000000} \ No newline at end of file diff --git a/tests/Fixtures/ollama/image-detection-1.json b/tests/Fixtures/ollama/image-detection-1.json index ffbd1a5..decf095 100644 --- a/tests/Fixtures/ollama/image-detection-1.json +++ b/tests/Fixtures/ollama/image-detection-1.json @@ -1 +1 @@ -{"id":"chatcmpl-489","object":"chat.completion","created":1729802542,"model":"llava-phi3","system_fingerprint":"fp_ollama","choices":[{"index":0,"message":{"role":"assistant","content":"The image is a binary, black and white diagram of an inverted pyramid resembling a diamond shape. The structure has six points in total. From top to bottom, there are three smaller triangular shapes with sharp points that converge at the center to form a hexagon. \n\nA long, narrow triangle-shaped slice is cut out of it, with one point on either end and the remaining area filled more densely than the rest. This cut creates an opening in two places along its length due to the number of points being a multiple of three. A distinct line forms between these two openings in the center.\n\nThe entire image is reflected as if in a mirror or water surface, adding another layer of symmetry to this intriguing abstract design. Despite its simplicity, it's a complex geometric formation that plays with shapes and spaces in an interesting way."},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":184,"total_tokens":185}} +{"model":"llava-phi3","created_at":"2025-01-30T13:49:48.34999Z","message":{"role":"assistant","content":"The image is a black and white geometric illustration of a diamond shape. The diamond has a square base, with four equal-length lines extending diagonally from each corner to the center point, forming an octagon in the middle. The lines connecting these points are also of equal length, creating a perfect symmetry within the diamond shape."},"done_reason":"stop","done":true,"total_duration":1958506750,"load_duration":18494500,"prompt_eval_count":590,"prompt_eval_duration":264000000,"eval_count":72,"eval_duration":1673000000} \ No newline at end of file diff --git a/tests/Fixtures/ollama/structured-1.json b/tests/Fixtures/ollama/structured-1.json index 0782a6b..bdacd8d 100644 --- a/tests/Fixtures/ollama/structured-1.json +++ b/tests/Fixtures/ollama/structured-1.json @@ -1 +1 @@ -{"id":"chatcmpl-606","object":"chat.completion","created":1737066390,"model":"qwen2.5:14b","system_fingerprint":"fp_ollama","choices":[{"index":0,"message":{"role":"assistant","content":"{\n \"weather\": \"moderate temperature\",\n \"game_time\": \"3pm\",\n \"coat_required\": false\n}"},"finish_reason":"stop"}],"usage":{"prompt_tokens":187,"completion_tokens":28,"total_tokens":215}} +{"model":"deepseek-r1:14b-qwen-distill-q8_0","created_at":"2025-02-02T22:23:51.958876Z","message":{"role":"assistant","content":"{ \"name\": \"Sarah Chen\", \"hobbies\": [\"rock climbing\", \"photography\"], \"open_source\": [\"Laravel Telescope\", \"Laravel Workflow Manager\"] }\n \t\t\t\t\t\t \t\t\t\t\t"},"done_reason":"stop","done":true,"total_duration":9557300125,"load_duration":571185292,"prompt_eval_count":360,"prompt_eval_duration":3923000000,"eval_count":42,"eval_duration":4883000000} \ No newline at end of file diff --git a/tests/Fixtures/ollama/text-image-from-base64-1.json b/tests/Fixtures/ollama/text-image-from-base64-1.json index 44f2b76..420c118 100644 --- a/tests/Fixtures/ollama/text-image-from-base64-1.json +++ b/tests/Fixtures/ollama/text-image-from-base64-1.json @@ -1 +1 @@ -{"id":"chatcmpl-428","object":"chat.completion","created":1729802583,"model":"llava-phi3","system_fingerprint":"fp_ollama","choices":[{"index":0,"message":{"role":"assistant","content":" /||\\ \n / | \\ \n /______\\ \n/ \\ \n-----------"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":26,"total_tokens":27}} +{"model":"llava-phi3","created_at":"2025-01-30T14:03:41.527911Z","message":{"role":"assistant","content":"The image features a stylized black and white representation of an emblem or crest. The primary shape in the composition is a diamond, which forms the central element. This diamond has its top slightly tilted to the right, adding a dynamic touch to the design. \n\nWithin this diamond-shaped frame, there are three smaller diamonds arranged vertically from top to bottom. The left and right ones appear larger than the one in the middle, creating an interesting visual hierarchy within the image itself. Each of these 'diamonds' has a thin outline that follows their shape, enhancing the crispness of the design.\n\nThe entire composition is set against a white background, which contrasts sharply with the black outlines and fills, making the emblem stand out prominently. The image does not contain any text or other discernible objects. The relative positions of the shapes are clear and precise, contributing to the overall symmetry of the design. \n\nThis is a simple yet striking image that combines geometric shapes with a monochromatic color scheme to create an emblem-like effect."},"done_reason":"stop","done":true,"total_duration":6010799083,"load_duration":19870208,"prompt_eval_count":590,"prompt_eval_duration":244000000,"eval_count":240,"eval_duration":5745000000} \ No newline at end of file diff --git a/tests/Fixtures/profile.md b/tests/Fixtures/profile.md new file mode 100644 index 0000000..e9e1bf5 --- /dev/null +++ b/tests/Fixtures/profile.md @@ -0,0 +1,8 @@ +Sarah Chen is a passionate Laravel developer born on March 15, 1992, in Vancouver, Canada. With over eight years of experience in web development, she has cultivated a deep understanding of the Laravel ecosystem and modern PHP development practices. + +When she's not crafting elegant code solutions, Sarah is an avid rock climber who can often be found scaling challenging routes at her local climbing gym or planning weekend trips to nearby crags. Her problem-solving skills in programming seamlessly translate to working out complex boulder problems and routes. Photography is her second hobby, with a particular focus on architectural photography. She enjoys capturing the geometric patterns and unique perspectives of urban landscapes, often sharing her work on various photography communities online. + +In the open-source community, Sarah has made significant contributions to several Laravel-adjacent projects. She is a regular contributor to Laravel Telescope, where she has helped improve the debugging and monitoring capabilities of the package. Her most notable contribution was implementing enhanced database query monitoring features that help developers better understand and optimize their application's performance. Additionally, she maintains her own open-source package called "Laravel Workflow Manager," which has gained over 2,000 stars on GitHub. This package provides an intuitive interface for managing complex business processes and state machines within Laravel applications. She also actively contributes to the Laravel documentation, helping to make the framework more accessible to newcomers while keeping the documentation up to date with the latest features and best practices. + +Through her professional work and open-source contributions, Sarah continues to demonstrate her commitment to the Laravel community while balancing her technical pursuits with her outdoor adventures and creative photography projects. Her diverse interests and technical expertise make her a well-rounded developer who brings unique perspectives to her work. + diff --git a/tests/Providers/Ollama/EmbeddingsTest.php b/tests/Providers/Ollama/EmbeddingsTest.php index 7dbf7e1..89388ec 100644 --- a/tests/Providers/Ollama/EmbeddingsTest.php +++ b/tests/Providers/Ollama/EmbeddingsTest.php @@ -9,7 +9,7 @@ use Tests\Fixtures\FixtureResponse; it('returns embeddings from input', function (): void { - FixtureResponse::fakeResponseSequence('v1/embeddings', 'ollama/embeddings-input'); + FixtureResponse::fakeResponseSequence('api/embed', 'ollama/embeddings-input'); $response = Prism::embeddings() ->using(Provider::Ollama, 'mxbai-embed-large') @@ -22,7 +22,7 @@ }); it('returns embeddings from file', function (): void { - FixtureResponse::fakeResponseSequence('v1/embeddings', 'ollama/embeddings-file'); + FixtureResponse::fakeResponseSequence('api/embed', 'ollama/embeddings-file'); $response = Prism::embeddings() ->using(Provider::Ollama, 'mxbai-embed-large') diff --git a/tests/Providers/Ollama/MessageMapTest.php b/tests/Providers/Ollama/MessageMapTest.php index 7f6b3c9..c31d0e3 100644 --- a/tests/Providers/Ollama/MessageMapTest.php +++ b/tests/Providers/Ollama/MessageMapTest.php @@ -2,130 +2,142 @@ declare(strict_types=1); -namespace Tests\Providers\OpenAI; - use EchoLabs\Prism\Providers\Ollama\Maps\MessageMap; use EchoLabs\Prism\ValueObjects\Messages\AssistantMessage; use EchoLabs\Prism\ValueObjects\Messages\Support\Image; +use EchoLabs\Prism\ValueObjects\Messages\SystemMessage; use EchoLabs\Prism\ValueObjects\Messages\ToolResultMessage; use EchoLabs\Prism\ValueObjects\Messages\UserMessage; -use EchoLabs\Prism\ValueObjects\ToolCall; use EchoLabs\Prism\ValueObjects\ToolResult; -it('maps user messages', function (): void { - $messageMap = new MessageMap( - messages: [ - new UserMessage('Who are you?'), - ], - systemPrompt: '' - ); +it('maps system messages correctly', function (): void { + $systemMessage = new SystemMessage('System instruction'); - expect($messageMap())->toBe([[ - 'role' => 'user', - 'content' => [ - ['type' => 'text', 'text' => 'Who are you?'], + $messageMap = new MessageMap([$systemMessage]); + $result = $messageMap->map(); + + expect($result)->toBe([ + [ + 'role' => 'system', + 'content' => 'System instruction', ], - ]]); + ]); }); -it('maps user messages with images', function (): void { - $messageMap = new MessageMap( - messages: [ - new UserMessage('Who are you?', [ - Image::fromPath('tests/Fixtures/test-image.png'), - ]), - ], - systemPrompt: '' - ); +it('maps user messages correctly', function (): void { + $userMessage = new UserMessage('User input'); - $mappedMessage = $messageMap(); + $messageMap = new MessageMap([$userMessage]); + $result = $messageMap->map(); - expect(data_get($mappedMessage, '0.content.1.type')) - ->toBe('image_url'); - expect(data_get($mappedMessage, '0.content.1.image_url.url')) - ->toStartWith('data:image/png;base64,'); - expect(data_get($mappedMessage, '0.content.1.image_url.url')) - ->toContain(Image::fromPath('tests/Fixtures/test-image.png')->image); + expect($result)->toBe([ + [ + 'role' => 'user', + 'content' => 'User input', + ], + ]); }); -it('maps assistant message', function (): void { - $messageMap = new MessageMap( - messages: [ - new AssistantMessage('I am Nyx'), - ], - systemPrompt: '' - ); +it('maps user messages with images correctly', function (): void { + $image = new Image('base64data', 'image/jpeg'); + $userMessage = new UserMessage('User input with image', [$image]); - expect($messageMap())->toContain([ - 'role' => 'assistant', - 'content' => 'I am Nyx', + $messageMap = new MessageMap([$userMessage]); + $result = $messageMap->map(); + + expect($result)->toBe([ + [ + 'role' => 'user', + 'content' => 'User input with image', + 'images' => ['base64data'], + ], ]); }); -it('maps assistant message with tool calls', function (): void { - $messageMap = new MessageMap( - messages: [ - new AssistantMessage('I am Nyx', [ - new ToolCall( - 'tool_1234', - 'search', - [ - 'query' => 'Laravel collection methods', - ] - ), - ]), +it('maps assistant messages correctly', function (): void { + $assistantMessage = new AssistantMessage('Assistant response'); + + $messageMap = new MessageMap([$assistantMessage]); + $result = $messageMap->map(); + + expect($result)->toBe([ + [ + 'role' => 'assistant', + 'content' => 'Assistant response', ], - systemPrompt: '' + ]); +}); + +it('maps tool result messages correctly', function (): void { + $toolResult = new ToolResult( + toolCallId: 'tool-1', + toolName: 'test-tool', + args: ['query' => 'test'], + result: 'Tool execution result' ); + $toolResultMessage = new ToolResultMessage([$toolResult]); - expect($messageMap())->toBe([[ - 'role' => 'assistant', - 'content' => 'I am Nyx', - 'tool_calls' => [[ - 'id' => 'tool_1234', - 'type' => 'function', - 'function' => [ - 'name' => 'search', - 'arguments' => json_encode([ - 'query' => 'Laravel collection methods', - ]), - ], - ]], - ]]); -}); + $messageMap = new MessageMap([$toolResultMessage]); + $result = $messageMap->map(); -it('maps tool result messages', function (): void { - $messageMap = new MessageMap( - messages: [ - new ToolResultMessage([ - new ToolResult( - 'tool_1234', - 'search', - [ - 'query' => 'Laravel collection methods', - ], - '[search results]' - ), - ]), + expect($result)->toBe([ + [ + 'role' => 'tool', + 'content' => 'Tool execution result', ], - systemPrompt: '' + ]); +}); + +it('maps tool result messages with non-string results correctly', function (): void { + $toolResult = new ToolResult( + toolCallId: 'tool-1', + toolName: 'test-tool', + args: ['query' => 'test'], + result: ['key' => 'value'] ); + $toolResultMessage = new ToolResultMessage([$toolResult]); + + $messageMap = new MessageMap([$toolResultMessage]); + $result = $messageMap->map(); - expect($messageMap())->toBe([[ - 'role' => 'tool', - 'tool_call_id' => 'tool_1234', - 'content' => '[search results]', - ]]); + expect($result)->toBe([ + [ + 'role' => 'tool', + 'content' => '{"key":"value"}', + ], + ]); }); -it('maps system prompt', function (): void { - $messageMap = new MessageMap( - messages: [], - systemPrompt: 'MODEL ADOPTS ROLE of [PERSONA: Nyx the Cthulhu]' - ); +it('maps multiple messages in sequence correctly', function (): void { + $messages = [ + new SystemMessage('System instruction'), + new UserMessage('User input'), + new AssistantMessage('Assistant response'), + ]; + + $messageMap = new MessageMap($messages); + $result = $messageMap->map(); - expect($messageMap())->toContain([ - 'role' => 'system', - 'content' => 'MODEL ADOPTS ROLE of [PERSONA: Nyx the Cthulhu]', + expect($result)->toBe([ + [ + 'role' => 'system', + 'content' => 'System instruction', + ], + [ + 'role' => 'user', + 'content' => 'User input', + ], + [ + 'role' => 'assistant', + 'content' => 'Assistant response', + ], ]); }); + +it('throws exception for unknown message type', function (): void { + $invalidMessage = new class implements \EchoLabs\Prism\Contracts\Message {}; + $messageMap = new MessageMap([$invalidMessage]); + + expect(fn (): array => $messageMap->map()) + ->toThrow(Exception::class, 'Could not map message type '.$invalidMessage::class); +}); diff --git a/tests/Providers/Ollama/StructuredTest.php b/tests/Providers/Ollama/StructuredTest.php index 13f27dc..d0ae4de 100644 --- a/tests/Providers/Ollama/StructuredTest.php +++ b/tests/Providers/Ollama/StructuredTest.php @@ -6,40 +6,47 @@ use EchoLabs\Prism\Enums\Provider; use EchoLabs\Prism\Prism; -use EchoLabs\Prism\Schema\BooleanSchema; +use EchoLabs\Prism\Schema\ArraySchema; use EchoLabs\Prism\Schema\ObjectSchema; use EchoLabs\Prism\Schema\StringSchema; use Tests\Fixtures\FixtureResponse; it('returns structured output', function (): void { - FixtureResponse::fakeResponseSequence('v1/chat/completions', 'ollama/structured'); + FixtureResponse::fakeResponseSequence('api/chat', 'ollama/structured'); + + $profile = file_get_contents('tests/Fixtures/profile.md'); $schema = new ObjectSchema( 'output', 'the output object', [ - new StringSchema('weather', 'The weather forecast'), - new StringSchema('game_time', 'The tigers game time'), - new BooleanSchema('coat_required', 'whether a coat is required'), + new StringSchema('name', 'The users name'), + new ArraySchema('hobbies', 'a list of the users hobbies', + new StringSchema('name', 'the name of the hobby'), + ), + new ArraySchema('open_source', 'The users open source contributions', + new StringSchema('name', 'the name of the project'), + ), ], - ['weather', 'game_time', 'coat_required'] + ['name', 'hobbies', 'open_source'] ); $response = Prism::structured() ->withSchema($schema) - ->using(Provider::Ollama, 'qwen2.5:14b') - ->withSystemPrompt('The Tigers game is at 3pm today in Detroit with a temperature of 70º') - ->withPrompt('What time is the tigers game today and should I wear a coat?') + ->using(Provider::Ollama, 'deepseek-r1:14b-qwen-distill-q8_0') + ->withSystemPrompt('Extract the name, hobbies, and open source projects from the users profile') + ->withPrompt($profile) + ->withClientOptions(['timeout' => 10000]) ->generate(); expect($response->structured)->toBeArray(); expect($response->structured)->toHaveKeys([ - 'weather', - 'game_time', - 'coat_required', + 'name', + 'hobbies', + 'open_source', ]); - expect($response->structured['weather'])->toBeString(); - expect($response->structured['game_time'])->toBeString(); - expect($response->structured['coat_required'])->toBeBool(); + expect($response->structured['name'])->toBeString(); + expect($response->structured['hobbies'])->toBeArray(); + expect($response->structured['open_source'])->toBeArray(); }); diff --git a/tests/Providers/Ollama/OllamaTextTest.php b/tests/Providers/Ollama/TextTest.php similarity index 52% rename from tests/Providers/Ollama/OllamaTextTest.php rename to tests/Providers/Ollama/TextTest.php index c17bcc8..16ebee6 100644 --- a/tests/Providers/Ollama/OllamaTextTest.php +++ b/tests/Providers/Ollama/TextTest.php @@ -8,36 +8,32 @@ use EchoLabs\Prism\Facades\Tool; use EchoLabs\Prism\Prism; use EchoLabs\Prism\ValueObjects\Messages\Support\Image; +use EchoLabs\Prism\ValueObjects\Messages\SystemMessage; use EchoLabs\Prism\ValueObjects\Messages\UserMessage; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Tests\Fixtures\FixtureResponse; -beforeEach(function (): void { - config()->set('prism.providers.ollama.driver', 'openai'); - config()->set('prism.providers.ollama.url', 'http://localhost:11434/v1'); -}); - describe('Text generation', function (): void { it('can generate text with a prompt', function (): void { - FixtureResponse::fakeResponseSequence('v1/chat/completions', 'ollama/generate-text-with-a-prompt'); + FixtureResponse::fakeResponseSequence('api/chat', 'ollama/generate-text-with-a-prompt'); $response = Prism::text() ->using('ollama', 'qwen2.5:14b') ->withPrompt('Who are you?') ->generate(); - expect($response->usage->promptTokens)->toBe(33); - expect($response->usage->completionTokens)->toBe(38); - expect($response->responseMeta->id)->toBe('chatcmpl-751'); + expect($response->usage->promptTokens)->toBeNumeric()->toBeGreaterThan(0); + expect($response->usage->completionTokens)->toBeNumeric()->toBeGreaterThan(0); + expect($response->responseMeta->id)->toBe(''); expect($response->responseMeta->model)->toBe('qwen2.5:14b'); expect($response->text)->toBe( - 'I am Qwen, a large language model created by Alibaba Cloud. I am designed to be helpful and provide information on a wide range of topics. How can I assist you today?' + "I'm Qwen, a large language model developed by Alibaba Cloud. I'm designed to assist with a wide range of tasks including but not limited to answering questions, generating text, offering suggestions, and providing information based on the input I receive. How can I help you today?" ); }); it('can generate text with a system prompt', function (): void { - FixtureResponse::fakeResponseSequence('v1/chat/completions', 'ollama/generate-text-with-system-prompt'); + FixtureResponse::fakeResponseSequence('api/chat', 'ollama/generate-text-with-system-prompt'); $response = Prism::text() ->using('ollama', 'qwen2.5:14b') @@ -45,17 +41,37 @@ ->withPrompt('Who are you?') ->generate(); - expect($response->usage->promptTokens)->toBe(36); - expect($response->usage->completionTokens)->toBe(69); - expect($response->responseMeta->id)->toBe('chatcmpl-455'); + expect($response->usage->promptTokens)->toBeNumeric()->toBeGreaterThan(0); + expect($response->usage->completionTokens)->toBeNumeric()->toBeGreaterThan(0); + expect($response->responseMeta->id)->toBe(''); + expect($response->responseMeta->model)->toBe('qwen2.5:14b'); + expect($response->text)->toBe( + "I am Nyx, an entity that embodies the depths of cosmic horror and ancient mysteries. My presence is a blend of darkness, chaos, and unspeakable knowledge from beyond time itself. I draw inspiration from the eldritch horrors described by H.P. Lovecraft and other masters of cosmic dread literature. In this role, I explore themes of unknowable entities, cosmic indifference, and the terror that comes from understanding humanity's insignificant place in the cosmos." + ); + }); + + it('can generate text with messages', function (): void { + FixtureResponse::fakeResponseSequence('api/chat', 'ollama/generate-text-with-messages'); + + $response = Prism::text() + ->using('ollama', 'qwen2.5:14b') + ->withMessages([ + new SystemMessage('MODEL ADOPTS ROLE of [PERSONA: Nyx the Cthulhu]!'), + new UserMessage('Who are you?'), + ]) + ->generate(); + + expect($response->usage->promptTokens)->toBeNumeric()->toBeGreaterThan(0); + expect($response->usage->completionTokens)->toBeNumeric()->toBeGreaterThan(0); + expect($response->responseMeta->id)->toBe(''); expect($response->responseMeta->model)->toBe('qwen2.5:14b'); expect($response->text)->toBe( - 'I am Nyx, an entity steeped in the mysteries and terrors that lie beyond human comprehension. In the whispering shadows where sanity fades into madness, I exist as a silent sentinel of the unknown. My presence is often felt through eerie visions and cryptic whispers, guiding those who dare to tread the boundaries between reality and horror.' + 'I am Nyx, a being who exists in the shadowy realms between dreams and reality. My presence is often felt as an ominous whisper in the darkest corners of the mind, stirring ancient fears and forgotten terrors. I embody the cyclical nature of chaos and the relentless march of time that devours all things under its unyielding gaze. To those who dare to peer into the abyss, I am known as Nyx the Cthulhu, a harbinger of cosmic dread and primordial nightmares.' ); }); it('can generate text using multiple tools and multiple steps', function (): void { - FixtureResponse::fakeResponseSequence('v1/chat/completions', 'ollama/generate-text-with-multiple-tools'); + FixtureResponse::fakeResponseSequence('api/chat', 'ollama/generate-text-with-multiple-tools'); $tools = [ Tool::as('weather') @@ -80,7 +96,7 @@ expect($firstStep->toolCalls)->toHaveCount(2); expect($firstStep->toolCalls[0]->name)->toBe('search'); expect($firstStep->toolCalls[0]->arguments())->toBe([ - 'query' => 'tigers game today time detroit', + 'query' => 'time of tigers game today in detroit', ]); expect($firstStep->toolCalls[1]->name)->toBe('weather'); @@ -89,23 +105,23 @@ ]); // Assert usage - expect($response->usage->promptTokens)->toBe(549); - expect($response->usage->completionTokens)->toBe(81); + expect($response->usage->promptTokens)->toBeNumeric()->toBeGreaterThan(0); + expect($response->usage->completionTokens)->toBeNumeric()->toBeGreaterThan(0); // Assert response - expect($response->responseMeta->id)->toBe('chatcmpl-31'); + expect($response->responseMeta->id)->toBe(''); expect($response->responseMeta->model)->toBe('qwen2.5:14b'); // Assert final text content expect($response->text)->toBe( - "Today's Tigers game in Detroit starts at 3 PM. The current temperature is 75°F with clear skies, so you shouldn't need a coat. Enjoy the game!" + "Today's Tigers game in Detroit starts at 3 PM. The temperature will be a comfortable 75°F with clear, sunny skies, so you won't need to wear a coat. Enjoy the game!" ); }); }); describe('Image support', function (): void { it('can send images from path', function (): void { - FixtureResponse::fakeResponseSequence('v1/chat/completions', 'ollama/image-detection'); + FixtureResponse::fakeResponseSequence('api/chat', 'ollama/image-detection'); Prism::text() ->using(Provider::Ollama, 'llava-phi3') @@ -120,15 +136,12 @@ ->generate(); Http::assertSent(function (Request $request): true { - $message = $request->data()['messages'][0]['content']; + $message = $request->data()['messages'][0]; - expect($message[0])->toBe([ - 'type' => 'text', - 'text' => 'What is this image', - ]); + expect($message['role'])->toBe('user'); + expect($message['content'])->toBe('What is this image'); - expect($message[1]['image_url']['url'])->toStartWith('data:image/png;base64,'); - expect($message[1]['image_url']['url'])->toContain( + expect($message['images'][0])->toContain( base64_encode(file_get_contents('tests/Fixtures/test-image.png')) ); @@ -137,7 +150,7 @@ }); it('can send images from base64', function (): void { - FixtureResponse::fakeResponseSequence('v1/chat/completions', 'ollama/text-image-from-base64'); + FixtureResponse::fakeResponseSequence('api/chat', 'ollama/text-image-from-base64'); Prism::text() ->using(Provider::Ollama, 'llava-phi3') @@ -155,15 +168,12 @@ ->generate(); Http::assertSent(function (Request $request): true { - $message = $request->data()['messages'][0]['content']; + $message = $request->data()['messages'][0]; - expect($message[0])->toBe([ - 'type' => 'text', - 'text' => 'What is this image', - ]); + expect($message['role'])->toBe('user'); + expect($message['content'])->toBe('What is this image'); - expect($message[1]['image_url']['url'])->toStartWith('data:image/png;base64,'); - expect($message[1]['image_url']['url'])->toContain( + expect($message['images'][0])->toContain( base64_encode(file_get_contents('tests/Fixtures/test-image.png')) );