diff --git a/config/ai.php b/config/ai.php index 48c6c61e3..6b571db50 100644 --- a/config/ai.php +++ b/config/ai.php @@ -127,6 +127,14 @@ 'url' => env('VOYAGEAI_URL', 'https://api.voyageai.com/v1'), ], + 'workersai' => [ + 'driver' => 'workersai', + 'key' => env('CLOUDFLARE_AI_API_KEY'), + 'account_id' => env('CLOUDFLARE_ACCOUNT_ID'), + 'gateway' => env('WORKERSAI_GATEWAY'), + 'url' => env('WORKERSAI_URL'), + ], + 'xai' => [ 'driver' => 'xai', 'key' => env('XAI_API_KEY'), diff --git a/src/AiManager.php b/src/AiManager.php index 1bea5c523..00e659d47 100644 --- a/src/AiManager.php +++ b/src/AiManager.php @@ -32,6 +32,7 @@ use Laravel\Ai\Providers\OpenRouterProvider; use Laravel\Ai\Providers\Provider; use Laravel\Ai\Providers\VoyageAiProvider; +use Laravel\Ai\Providers\WorkersAiProvider; use Laravel\Ai\Providers\XaiProvider; use LogicException; @@ -424,6 +425,17 @@ public function createVoyageaiDriver(array $config): VoyageAiProvider ); } + /** + * Create a Workers AI powered instance. + */ + public function createWorkersaiDriver(array $config): WorkersAiProvider + { + return new WorkersAiProvider( + $config, + $this->app->make(Dispatcher::class), + ); + } + /** * Create an xAI powered instance. */ diff --git a/src/Gateway/WorkersAi/Concerns/BuildsTextRequests.php b/src/Gateway/WorkersAi/Concerns/BuildsTextRequests.php new file mode 100644 index 000000000..2c8584265 --- /dev/null +++ b/src/Gateway/WorkersAi/Concerns/BuildsTextRequests.php @@ -0,0 +1,77 @@ + $model, + 'messages' => $this->mapMessagesToChat($messages, $instructions), + ]; + + if (filled($tools)) { + $mappedTools = $this->mapTools($tools); + + if (filled($mappedTools)) { + $body['tool_choice'] = 'auto'; + $body['tools'] = $mappedTools; + } + } + + if (filled($schema)) { + $body['response_format'] = $this->buildResponseFormat($schema); + } + + if (! is_null($options?->maxTokens)) { + $body['max_completion_tokens'] = $options->maxTokens; + } + + if (! is_null($options?->temperature)) { + $body['temperature'] = $options->temperature; + } + + $providerOptions = $options?->providerOptions($provider->driver()); + + if (filled($providerOptions)) { + $body = array_merge($body, Arr::except($providerOptions, ['session_affinity'])); + } + + return $body; + } + + /** + * Build the response format options for structured output. + */ + protected function buildResponseFormat(array $schema): array + { + $objectSchema = new ObjectSchema($schema); + + $schemaArray = $objectSchema->toSchema(); + + return [ + 'type' => 'json_schema', + 'json_schema' => [ + 'name' => $schemaArray['name'] ?? 'schema_definition', + 'schema' => Arr::except($schemaArray, ['name']), + 'strict' => true, + ], + ]; + } +} diff --git a/src/Gateway/WorkersAi/Concerns/CreatesWorkersAiClient.php b/src/Gateway/WorkersAi/Concerns/CreatesWorkersAiClient.php new file mode 100644 index 000000000..ce21833a9 --- /dev/null +++ b/src/Gateway/WorkersAi/Concerns/CreatesWorkersAiClient.php @@ -0,0 +1,73 @@ +baseUrl($provider)) + ->withToken($provider->providerCredentials()['key']) + ->timeout($timeout ?? 60) + ->throw(); + + $additionalConfig = $provider->additionalConfiguration(); + + if (! empty($additionalConfig['session_affinity'])) { + $client->withHeaders(['x-session-affinity' => $additionalConfig['session_affinity']]); + } + + return $client; + } + + /** + * Get the base URL for the Workers AI API. + */ + protected function baseUrl(Provider $provider): string + { + $config = $provider->additionalConfiguration(); + + if (! empty($config['url'])) { + return rtrim($config['url'], '/'); + } + + $accountId = $config['account_id'] ?? ''; + + if (empty($accountId)) { + throw new AiException( + "Workers AI requires an 'account_id' or explicit 'url' in your provider configuration. " + .'Set CLOUDFLARE_ACCOUNT_ID in your .env file or provide a WORKERSAI_URL.' + ); + } + + if (! empty($config['gateway'])) { + return "https://gateway.ai.cloudflare.com/v1/{$accountId}/{$config['gateway']}/workers-ai/v1"; + } + + return "https://api.cloudflare.com/client/v4/accounts/{$accountId}/ai/v1"; + } + + /** + * Validate model name compatibility with the configured endpoint. + */ + protected function validateModelName(Provider $provider, string $model): void + { + $url = $this->baseUrl($provider); + + if (str_ends_with(rtrim($url, '/'), '/compat') && ! str_starts_with($model, 'workers-ai/')) { + throw new AiException( + "Workers AI model '{$model}' requires the 'workers-ai/' prefix when using the /compat endpoint. " + ."Either prefix your model name (e.g., 'workers-ai/{$model}') or use the 'gateway' config option " + ."instead of an explicit URL — this handles routing automatically with no model name changes." + ); + } + } +} diff --git a/src/Gateway/WorkersAi/Concerns/HandlesTextStreaming.php b/src/Gateway/WorkersAi/Concerns/HandlesTextStreaming.php new file mode 100644 index 000000000..301b8b890 --- /dev/null +++ b/src/Gateway/WorkersAi/Concerns/HandlesTextStreaming.php @@ -0,0 +1,409 @@ +maxSteps; + + $messageId = $this->generateEventId(); + $reasoningId = ''; + $streamStartEmitted = false; + $textStartEmitted = false; + $reasoningStartEmitted = false; + $currentText = ''; + $pendingToolCalls = []; + $usage = null; + $finishReason = null; + + foreach ($this->parseServerSentEvents($streamBody) as $data) { + if (isset($data['error'])) { + yield (new Error( + $this->generateEventId(), + $data['error']['code'] ?? 'unknown_error', + $data['error']['message'] ?? 'Unknown error', + false, + time(), + ))->withInvocationId($invocationId); + + return; + } + + $choice = $data['choices'][0] ?? null; + + if (! $choice) { + if (isset($data['usage'])) { + $usage = new Usage( + $data['usage']['prompt_tokens'] ?? 0, + $data['usage']['completion_tokens'] ?? 0, + 0, + 0, + $data['usage']['reasoning_tokens'] ?? 0, + ); + } + + continue; + } + + $delta = $choice['delta'] ?? []; + + if (! $streamStartEmitted) { + $streamStartEmitted = true; + + yield (new StreamStart( + $this->generateEventId(), + $provider->name(), + $data['model'] ?? $model, + time(), + ))->withInvocationId($invocationId); + } + + $reasoningDelta = $delta['reasoning_content'] + ?? $delta['reasoning'] + ?? $delta['thinking'] + ?? null; + + if (is_string($reasoningDelta) && $reasoningDelta !== '') { + if (! $reasoningStartEmitted) { + $reasoningStartEmitted = true; + $reasoningId = $this->generateEventId(); + + yield (new ReasoningStart( + $this->generateEventId(), + $reasoningId, + time(), + ))->withInvocationId($invocationId); + } + + yield (new ReasoningDelta( + $this->generateEventId(), + $reasoningId, + $reasoningDelta, + time(), + ))->withInvocationId($invocationId); + } + + if ($reasoningStartEmitted && isset($delta['content']) && $delta['content'] !== '' && $reasoningDelta === null) { + $reasoningStartEmitted = false; + + yield (new ReasoningEnd( + $this->generateEventId(), + $reasoningId, + time(), + ))->withInvocationId($invocationId); + + $reasoningId = ''; + } + + if (isset($delta['content']) && $delta['content'] !== '') { + if (! $textStartEmitted) { + $textStartEmitted = true; + + yield (new TextStart( + $this->generateEventId(), + $messageId, + time(), + ))->withInvocationId($invocationId); + } + + $currentText .= $delta['content']; + + yield (new TextDelta( + $this->generateEventId(), + $messageId, + $delta['content'], + time(), + ))->withInvocationId($invocationId); + } + + if (isset($delta['tool_calls'])) { + foreach ($delta['tool_calls'] as $tcDelta) { + $idx = $tcDelta['index']; + + if (! isset($pendingToolCalls[$idx])) { + $pendingToolCalls[$idx] = [ + 'id' => $tcDelta['id'] ?? '', + 'name' => $tcDelta['function']['name'] ?? '', + 'arguments' => '', + ]; + } + + if (isset($tcDelta['function']['arguments'])) { + $pendingToolCalls[$idx]['arguments'] .= $tcDelta['function']['arguments']; + } + } + } + + if (isset($choice['finish_reason']) && $choice['finish_reason'] !== null) { + $finishReason = $choice['finish_reason']; + } + + if (isset($data['usage'])) { + $usage = new Usage( + $data['usage']['prompt_tokens'] ?? 0, + $data['usage']['completion_tokens'] ?? 0, + 0, + 0, + $data['usage']['reasoning_tokens'] ?? 0, + ); + } + } + + if ($reasoningStartEmitted) { + yield (new ReasoningEnd( + $this->generateEventId(), + $reasoningId, + time(), + ))->withInvocationId($invocationId); + } + + if ($textStartEmitted) { + yield (new TextEnd( + $this->generateEventId(), + $messageId, + time(), + ))->withInvocationId($invocationId); + } + + if (filled($pendingToolCalls) && $finishReason === 'tool_calls') { + $mappedToolCalls = $this->mapStreamToolCalls($pendingToolCalls); + + foreach ($mappedToolCalls as $toolCall) { + yield (new ToolCallEvent( + $this->generateEventId(), + $toolCall, + time(), + ))->withInvocationId($invocationId); + } + + yield from $this->handleStreamingToolCalls( + $invocationId, + $provider, + $model, + $tools, + $schema, + $options, + $mappedToolCalls, + $currentText, + $instructions, + $originalMessages, + $depth, + $maxSteps, + $priorChatMessages, + $timeout, + ); + + return; + } + + yield (new StreamEnd( + $this->generateEventId(), + $this->extractFinishReason(['finish_reason' => $finishReason ?? ''])->value, + $usage ?? new Usage(0, 0), + time(), + ))->withInvocationId($invocationId); + } + + /** + * Handle tool calls detected during streaming. + */ + protected function handleStreamingToolCalls( + string $invocationId, + Provider $provider, + string $model, + array $tools, + ?array $schema, + ?TextGenerationOptions $options, + array $mappedToolCalls, + string $currentText, + ?string $instructions, + array $originalMessages, + int $depth, + ?int $maxSteps, + array $priorChatMessages, + ?int $timeout = null, + ): Generator { + $toolResults = []; + + foreach ($mappedToolCalls as $toolCall) { + $tool = $this->findTool($toolCall->name, $tools); + + if ($tool === null) { + continue; + } + + $result = $this->executeTool($tool, $toolCall->arguments); + + $toolResult = new ToolResult( + $toolCall->id, + $toolCall->name, + $toolCall->arguments, + $result, + $toolCall->resultId, + ); + + $toolResults[] = $toolResult; + + yield (new ToolResultEvent( + $this->generateEventId(), + $toolResult, + true, + null, + time(), + ))->withInvocationId($invocationId); + } + + if ($depth + 1 < ($maxSteps ?? round(count($tools) * 1.5))) { + $assistantMsg = ['role' => 'assistant']; + + if (filled($currentText)) { + $assistantMsg['content'] = $currentText; + } + + $assistantMsg['tool_calls'] = array_map( + fn (ToolCall $toolCall) => $this->serializeToolCallToChat($toolCall), $mappedToolCalls + ); + + $toolResultMessages = []; + + foreach ($toolResults as $toolResult) { + $toolResultMessages[] = [ + 'role' => 'tool', + 'tool_call_id' => $toolResult->resultId ?? $toolResult->id, + 'content' => $this->serializeToolResultOutput($toolResult->result), + ]; + } + + $updatedPriorMessages = [...$priorChatMessages, $assistantMsg, ...$toolResultMessages]; + + $chatMessages = [ + ...$this->mapMessagesToChat($originalMessages, $instructions), + ...$updatedPriorMessages, + ]; + + $body = [ + 'model' => $model, + 'messages' => $chatMessages, + 'stream' => true, + 'stream_options' => ['include_usage' => true], + ]; + + if (filled($tools)) { + $mappedTools = $this->mapTools($tools); + + if (filled($mappedTools)) { + $body['tool_choice'] = 'auto'; + $body['tools'] = $mappedTools; + } + } + + if (filled($schema)) { + $body['response_format'] = $this->buildResponseFormat($schema); + } + + if (! is_null($options?->maxTokens)) { + $body['max_completion_tokens'] = $options->maxTokens; + } + + if (! is_null($options?->temperature)) { + $body['temperature'] = $options->temperature; + } + + $providerOptions = $options?->providerOptions($provider->driver()); + + if (filled($providerOptions)) { + $body = array_merge($body, $providerOptions); + } + + $response = $this->withErrorHandling( + $provider->name(), + fn () => $this->client($provider, $timeout) + ->withOptions(['stream' => true]) + ->post('chat/completions', $body), + ); + + yield from $this->processTextStream( + $invocationId, + $provider, + $model, + $tools, + $schema, + $options, + $response->getBody(), + $instructions, + $originalMessages, + $depth + 1, + $maxSteps, + $updatedPriorMessages, + $timeout, + ); + } else { + yield (new StreamEnd( + $this->generateEventId(), + 'stop', + new Usage(0, 0), + time(), + ))->withInvocationId($invocationId); + } + } + + /** + * Map raw streaming tool call data to ToolCall DTOs. + * + * @return array + */ + protected function mapStreamToolCalls(array $toolCalls): array + { + return array_map(fn (array $toolCall) => new ToolCall( + $toolCall['id'] ?? '', + $toolCall['name'] ?? '', + json_decode($toolCall['arguments'] ?? '{}', true) ?? [], + $toolCall['id'] ?? null, + ), array_values($toolCalls)); + } + + /** + * Generate a lowercase UUID v7 for use as a stream event ID. + */ + protected function generateEventId(): string + { + return strtolower((string) Str::uuid7()); + } +} diff --git a/src/Gateway/WorkersAi/Concerns/MapsAttachments.php b/src/Gateway/WorkersAi/Concerns/MapsAttachments.php new file mode 100644 index 000000000..4a061fd13 --- /dev/null +++ b/src/Gateway/WorkersAi/Concerns/MapsAttachments.php @@ -0,0 +1,69 @@ +map(function ($attachment) { + if (! $attachment instanceof File && ! $attachment instanceof UploadedFile) { + throw new InvalidArgumentException( + 'Unsupported attachment type ['.get_class($attachment).']' + ); + } + + return match (true) { + $attachment instanceof Base64Image => [ + 'type' => 'image_url', + 'image_url' => ['url' => 'data:'.$attachment->mime.';base64,'.$attachment->base64], + ], + $attachment instanceof RemoteImage => [ + 'type' => 'image_url', + 'image_url' => ['url' => $attachment->url], + ], + $attachment instanceof LocalImage => [ + 'type' => 'image_url', + 'image_url' => ['url' => 'data:'.($attachment->mimeType() ?? 'image/png').';base64,'.base64_encode(file_get_contents($attachment->path))], + ], + $attachment instanceof StoredImage => [ + 'type' => 'image_url', + 'image_url' => ['url' => 'data:'.($attachment->mimeType() ?? 'image/png').';base64,'.base64_encode( + Storage::disk($attachment->disk)->get($attachment->path) + )], + ], + $attachment instanceof UploadedFile && $this->isImage($attachment) => [ + 'type' => 'image_url', + 'image_url' => ['url' => 'data:'.$attachment->getClientMimeType().';base64,'.base64_encode($attachment->get())], + ], + default => throw new InvalidArgumentException('Workers AI does not support document attachments. Only image attachments are supported.'), + }; + })->all(); + } + + /** + * Determine if the given uploaded file is an image. + */ + protected function isImage(UploadedFile $attachment): bool + { + return in_array($attachment->getClientMimeType(), [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + ]); + } +} diff --git a/src/Gateway/WorkersAi/Concerns/MapsMessages.php b/src/Gateway/WorkersAi/Concerns/MapsMessages.php new file mode 100644 index 000000000..899622323 --- /dev/null +++ b/src/Gateway/WorkersAi/Concerns/MapsMessages.php @@ -0,0 +1,148 @@ + 'system', + 'content' => $instructions, + ]; + } + + foreach ($messages as $message) { + $message = Message::tryFrom($message); + + match ($message->role) { + MessageRole::User => $this->mapUserMessage($message, $chatMessages), + MessageRole::Assistant => $this->mapAssistantMessage($message, $chatMessages), + MessageRole::ToolResult => $this->mapToolResultMessage($message, $chatMessages), + }; + } + + return $chatMessages; + } + + /** + * Map a user message to Chat Completions format. + */ + protected function mapUserMessage(UserMessage|Message $message, array &$chatMessages): void + { + if (! $message instanceof UserMessage || $message->attachments->isEmpty()) { + $chatMessages[] = [ + 'role' => 'user', + 'content' => $this->coerceContentToString($message->content), + ]; + + return; + } + + $chatMessages[] = [ + 'role' => 'user', + 'content' => [ + ['type' => 'text', 'text' => $message->content], + ...$this->mapAttachments($message->attachments), + ], + ]; + } + + /** + * Map an assistant message to Chat Completions format. + */ + protected function mapAssistantMessage(AssistantMessage|Message $message, array &$chatMessages): void + { + $msg = ['role' => 'assistant']; + + if (filled($message->content)) { + $msg['content'] = $this->coerceContentToString($message->content); + } + + if ($message instanceof AssistantMessage && $message->toolCalls->isNotEmpty()) { + $msg['tool_calls'] = $message->toolCalls->map( + fn (ToolCall $toolCall) => $this->serializeToolCallToChat($toolCall) + )->all(); + } + + $chatMessages[] = $msg; + } + + /** + * Map a tool result message to Chat Completions format. + */ + protected function mapToolResultMessage(ToolResultMessage|Message $message, array &$chatMessages): void + { + if (! $message instanceof ToolResultMessage) { + return; + } + + foreach ($message->toolResults as $toolResult) { + $chatMessages[] = [ + 'role' => 'tool', + 'tool_call_id' => $toolResult->resultId ?? $toolResult->id, + 'content' => $this->serializeToolResultOutput($toolResult->result), + ]; + } + } + + /** + * Serialize a tool call DTO to Chat Completions array format. + */ + protected function serializeToolCallToChat(ToolCall $toolCall): array + { + return [ + 'id' => $toolCall->resultId ?? $toolCall->id, + 'type' => 'function', + 'function' => [ + 'name' => $toolCall->name, + 'arguments' => json_encode($toolCall->arguments ?: (object) []), + ], + ]; + } + + /** + * Serialize a tool result output value to a string. + */ + protected function serializeToolResultOutput(mixed $output): string + { + if (is_string($output)) { + return $output; + } + + return is_array($output) ? json_encode($output) : strval($output); + } + + /** + * Coerce content to a string. + */ + protected function coerceContentToString(mixed $content): string + { + if (is_string($content)) { + return $content; + } + + if (is_null($content)) { + return ''; + } + + if (is_array($content) || is_object($content)) { + return json_encode($content); + } + + return strval($content); + } +} diff --git a/src/Gateway/WorkersAi/Concerns/MapsTools.php b/src/Gateway/WorkersAi/Concerns/MapsTools.php new file mode 100644 index 000000000..96252e445 --- /dev/null +++ b/src/Gateway/WorkersAi/Concerns/MapsTools.php @@ -0,0 +1,58 @@ +mapTool($tool); + } + } + + return $mapped; + } + + /** + * Map a regular tool to a Chat Completions function definition. + */ + protected function mapTool(Tool $tool): array + { + $schema = $tool->schema(new JsonSchemaTypeFactory); + + $schemaArray = filled($schema) + ? (new ObjectSchema($schema))->toSchema() + : []; + + return [ + 'type' => 'function', + 'function' => [ + 'name' => class_basename($tool), + 'description' => (string) $tool->description(), + 'parameters' => [ + 'type' => 'object', + 'properties' => $schemaArray['properties'] ?? (object) [], + 'required' => $schemaArray['required'] ?? [], + 'additionalProperties' => false, + ], + ], + ]; + } +} diff --git a/src/Gateway/WorkersAi/Concerns/ParsesTextResponses.php b/src/Gateway/WorkersAi/Concerns/ParsesTextResponses.php new file mode 100644 index 000000000..056f10805 --- /dev/null +++ b/src/Gateway/WorkersAi/Concerns/ParsesTextResponses.php @@ -0,0 +1,379 @@ +processResponse( + $data, + $provider, + $structured, + $tools, + $schema, + new Collection, + new Collection, + instructions: $instructions, + originalMessages: $originalMessages, + maxSteps: $options?->maxSteps, + options: $options, + timeout: $timeout, + ); + } + + /** + * Process a single response, handling tool loops recursively. + */ + protected function processResponse( + array $data, + Provider $provider, + bool $structured, + array $tools, + ?array $schema, + Collection $steps, + Collection $messages, + ?string $instructions = null, + array $originalMessages = [], + int $depth = 0, + ?int $maxSteps = null, + ?TextGenerationOptions $options = null, + ?int $timeout = null, + ): TextResponse { + $choice = $data['choices'][0] ?? []; + $message = $choice['message'] ?? []; + $model = $data['model'] ?? ''; + + $text = $this->extractTextContent($message); + $rawToolCalls = $message['tool_calls'] ?? []; + $usage = $this->extractUsage($data); + $finishReason = $this->extractFinishReason($choice); + + $mappedToolCalls = array_map(fn (array $toolCall) => new ToolCall( + $toolCall['id'] ?? '', + $toolCall['function']['name'] ?? '', + json_decode($toolCall['function']['arguments'] ?? '{}', true) ?? [], + $toolCall['id'] ?? null, + ), $rawToolCalls); + + $step = new Step( + $text, + $mappedToolCalls, + [], + $finishReason, + $usage, + new Meta($provider->name(), $model), + ); + + $steps->push($step); + + $assistantMessage = new AssistantMessage($text, collect($mappedToolCalls)); + + $messages->push($assistantMessage); + + if ($finishReason === FinishReason::ToolCalls && + filled($mappedToolCalls) && + $steps->count() < ($maxSteps ?? round(count($tools) * 1.5))) { + $toolResults = $this->executeToolCalls($mappedToolCalls, $tools); + + $steps->pop(); + + $steps->push(new Step( + $text, + $mappedToolCalls, + $toolResults, + $finishReason, + $usage, + new Meta($provider->name(), $model), + )); + + $toolResultMessage = new ToolResultMessage(collect($toolResults)); + + $messages->push($toolResultMessage); + + return $this->continueWithToolResults( + $model, + $provider, + $structured, + $tools, + $schema, + $steps, + $messages, + $instructions, + $originalMessages, + $depth + 1, + $maxSteps, + $options, + $timeout, + ); + } + + $allToolCalls = $steps->flatMap(fn (Step $s) => $s->toolCalls); + $allToolResults = $steps->flatMap(fn (Step $s) => $s->toolResults); + + if ($structured) { + $structuredData = json_decode($text, true) ?? []; + + return (new StructuredTextResponse( + $structuredData, + $text, + $this->combineUsage($steps), + new Meta($provider->name(), $model), + ))->withToolCallsAndResults( + toolCalls: $allToolCalls, + toolResults: $allToolResults, + )->withSteps($steps); + } + + return (new TextResponse( + $text, + $this->combineUsage($steps), + new Meta($provider->name(), $model), + ))->withMessages($messages)->withSteps($steps); + } + + /** + * Execute tool calls and return tool results. + * + * @param array $toolCalls + * @param array $tools + * @return array + */ + protected function executeToolCalls(array $toolCalls, array $tools): array + { + $results = []; + + foreach ($toolCalls as $toolCall) { + $tool = $this->findTool($toolCall->name, $tools); + + if ($tool === null) { + continue; + } + + $result = $this->executeTool($tool, $toolCall->arguments); + + $results[] = new ToolResult( + $toolCall->id, + $toolCall->name, + $toolCall->arguments, + $result, + $toolCall->resultId, + ); + } + + return $results; + } + + /** + * Continue the conversation with tool results by making a follow-up request. + */ + protected function continueWithToolResults( + string $model, + Provider $provider, + bool $structured, + array $tools, + ?array $schema, + Collection $steps, + Collection $messages, + ?string $instructions, + array $originalMessages, + int $depth, + ?int $maxSteps, + ?TextGenerationOptions $options = null, + ?int $timeout = null, + ): TextResponse { + $chatMessages = $this->mapMessagesToChat($originalMessages, $instructions); + + foreach ($messages as $msg) { + if ($msg instanceof AssistantMessage) { + $mapped = ['role' => 'assistant']; + + if (filled($msg->content)) { + $mapped['content'] = $this->coerceContentToString($msg->content); + } + + if ($msg->toolCalls->isNotEmpty()) { + $mapped['tool_calls'] = $msg->toolCalls->map( + fn (ToolCall $toolCall) => $this->serializeToolCallToChat($toolCall) + )->all(); + } + + $chatMessages[] = $mapped; + } elseif ($msg instanceof ToolResultMessage) { + foreach ($msg->toolResults as $toolResult) { + $chatMessages[] = [ + 'role' => 'tool', + 'tool_call_id' => $toolResult->resultId ?? $toolResult->id, + 'content' => $this->serializeToolResultOutput($toolResult->result), + ]; + } + } + } + + $body = [ + 'model' => $model, + 'messages' => $chatMessages, + ]; + + if (filled($tools)) { + $mappedTools = $this->mapTools($tools); + + if (filled($mappedTools)) { + $body['tool_choice'] = 'auto'; + $body['tools'] = $mappedTools; + } + } + + if (filled($schema)) { + $body['response_format'] = $this->buildResponseFormat($schema); + } + + if (! is_null($options?->maxTokens)) { + $body['max_completion_tokens'] = $options->maxTokens; + } + + if (! is_null($options?->temperature)) { + $body['temperature'] = $options->temperature; + } + + $providerOptions = $options?->providerOptions($provider->driver()); + + if (filled($providerOptions)) { + $body = array_merge($body, $providerOptions); + } + + $response = $this->withErrorHandling( + $provider->name(), + fn () => $this->client($provider, $timeout)->post('chat/completions', $body), + ); + + $data = $response->json(); + + $this->validateTextResponse($data); + + return $this->processResponse( + $data, + $provider, + $structured, + $tools, + $schema, + $steps, + $messages, + $instructions, + $originalMessages, + $depth, + $maxSteps, + $options, + $timeout, + ); + } + + /** + * Extract text content from a message. + */ + protected function extractTextContent(array $message): string + { + $content = $message['content'] ?? ''; + + if (is_string($content)) { + return $content; + } + + if (is_array($content) || is_object($content)) { + return json_encode($content); + } + + return strval($content); + } + + /** + * Extract usage data from the response. + */ + protected function extractUsage(array $data): Usage + { + $usage = $data['usage'] ?? []; + + return new Usage( + $usage['prompt_tokens'] ?? 0, + $usage['completion_tokens'] ?? 0, + 0, + 0, + $usage['reasoning_tokens'] ?? 0, + ); + } + + /** + * Extract and map the finish reason from the response. + */ + protected function extractFinishReason(array $choice): FinishReason + { + return match ($choice['finish_reason'] ?? '') { + 'stop' => FinishReason::Stop, + 'tool_calls' => FinishReason::ToolCalls, + 'length' => FinishReason::Length, + 'content_filter' => FinishReason::ContentFilter, + default => FinishReason::Unknown, + }; + } + + /** + * Combine usage across all steps. + */ + protected function combineUsage(Collection $steps): Usage + { + return $steps->reduce( + fn (Usage $carry, Step $step) => $carry->add($step->usage), + new Usage(0, 0) + ); + } +} diff --git a/src/Gateway/WorkersAi/WorkersAiGateway.php b/src/Gateway/WorkersAi/WorkersAiGateway.php new file mode 100644 index 000000000..e61f1bc98 --- /dev/null +++ b/src/Gateway/WorkersAi/WorkersAiGateway.php @@ -0,0 +1,162 @@ +initializeToolCallbacks(); + } + + /** + * {@inheritdoc} + */ + public function generateText( + TextProvider $provider, + string $model, + ?string $instructions, + array $messages = [], + array $tools = [], + ?array $schema = null, + ?TextGenerationOptions $options = null, + ?int $timeout = null, + ): TextResponse { + $this->validateModelName($provider, $model); + + $body = $this->buildTextRequestBody( + $provider, + $model, + $instructions, + $messages, + $tools, + $schema, + $options, + ); + + $response = $this->withErrorHandling( + $provider->name(), + fn () => $this->client($provider, $timeout)->post('chat/completions', $body), + ); + + $data = $response->json(); + + $this->validateTextResponse($data); + + return $this->parseTextResponse( + $data, + $provider, + filled($schema), + $tools, + $schema, + $options, + $instructions, + $messages, + $timeout, + ); + } + + /** + * {@inheritdoc} + */ + public function streamText( + string $invocationId, + TextProvider $provider, + string $model, + ?string $instructions, + array $messages = [], + array $tools = [], + ?array $schema = null, + ?TextGenerationOptions $options = null, + ?int $timeout = null, + ): Generator { + $this->validateModelName($provider, $model); + + $body = $this->buildTextRequestBody( + $provider, + $model, + $instructions, + $messages, + $tools, + $schema, + $options, + ); + + $body['stream'] = true; + $body['stream_options'] = ['include_usage' => true]; + + $response = $this->withErrorHandling( + $provider->name(), + fn () => $this->client($provider, $timeout) + ->withOptions(['stream' => true]) + ->post('chat/completions', $body), + ); + + yield from $this->processTextStream( + $invocationId, + $provider, + $model, + $tools, + $schema, + $options, + $response->getBody(), + $instructions, + $messages, + timeout: $timeout, + ); + } + + /** + * {@inheritdoc} + */ + public function generateEmbeddings( + EmbeddingProvider $provider, + string $model, + array $inputs, + int $dimensions, + int $timeout = 30, + ): EmbeddingsResponse { + $this->validateModelName($provider, $model); + + $response = $this->withErrorHandling( + $provider->name(), + fn () => $this->client($provider, $timeout)->post('embeddings', [ + 'model' => $model, + 'input' => $inputs, + ]), + ); + + $data = $response->json(); + + return new EmbeddingsResponse( + collect($data['data'] ?? [])->pluck('embedding')->all(), + $data['usage']['total_tokens'] ?? 0, + new Meta($provider->name(), $model), + ); + } +} diff --git a/src/Providers/WorkersAiProvider.php b/src/Providers/WorkersAiProvider.php new file mode 100644 index 000000000..8211249ee --- /dev/null +++ b/src/Providers/WorkersAiProvider.php @@ -0,0 +1,90 @@ +workersAiGateway ??= new WorkersAiGateway($this->events); + } + + /** + * Get the provider's text gateway. + */ + public function textGateway(): TextGateway + { + return $this->textGateway ??= $this->workersAiGateway(); + } + + /** + * Get the provider's embedding gateway. + */ + public function embeddingGateway(): EmbeddingGateway + { + return $this->embeddingGateway ??= $this->workersAiGateway(); + } + + /** + * Get the name of the default text model. + */ + public function defaultTextModel(): string + { + return $this->config['models']['text']['default'] ?? '@cf/meta/llama-3.3-70b-instruct-fp8-fast'; + } + + /** + * Get the name of the cheapest text model. + */ + public function cheapestTextModel(): string + { + return $this->config['models']['text']['cheapest'] ?? '@cf/meta/llama-3.1-8b-instruct'; + } + + /** + * Get the name of the smartest text model. + */ + public function smartestTextModel(): string + { + return $this->config['models']['text']['smartest'] ?? '@cf/moonshotai/kimi-k2.5'; + } + + /** + * Get the name of the default embeddings model. + */ + public function defaultEmbeddingsModel(): string + { + return $this->config['models']['embeddings']['default'] ?? '@cf/baai/bge-large-en-v1.5'; + } + + /** + * Get the default dimensions of the default embeddings model. + */ + public function defaultEmbeddingsDimensions(): int + { + return $this->config['models']['embeddings']['dimensions'] ?? 1024; + } +} diff --git a/tests/Feature/Providers/WorkersAi/AgentFakeTest.php b/tests/Feature/Providers/WorkersAi/AgentFakeTest.php new file mode 100644 index 000000000..043352ec4 --- /dev/null +++ b/tests/Feature/Providers/WorkersAi/AgentFakeTest.php @@ -0,0 +1,45 @@ +prompt('Hello'); + + expect($response->text)->toBe('Test response'); +}); + +test('workersai agent fake with closure', function () { + WorkersAiAgent::fake(fn (string $prompt) => "Echo: {$prompt}"); + + $response = (new WorkersAiAgent)->prompt('Hello world'); + + expect($response->text)->toBe('Echo: Hello world'); +}); + +test('workersai agent fake with no predefined responses', function () { + WorkersAiAgent::fake(); + + $response = (new WorkersAiAgent)->prompt('Hello'); + + expect($response->text)->toBe('Fake response for prompt: Hello'); +}); + +test('workersai agent fake records prompts', function () { + WorkersAiAgent::fake(); + + (new WorkersAiAgent)->prompt('Hello'); + + WorkersAiAgent::assertPrompted('Hello'); + WorkersAiAgent::assertNotPrompted('Goodbye'); +}); + +test('workersai agent stream can be faked', function () { + WorkersAiAgent::fake(['Streamed response']); + + $response = (new WorkersAiAgent)->stream('Hello'); + $response->each(fn () => true); + + expect($response->text)->toBe('Streamed response'); +}); diff --git a/tests/Feature/Providers/WorkersAi/BaseUrlTest.php b/tests/Feature/Providers/WorkersAi/BaseUrlTest.php new file mode 100644 index 000000000..d5fd5dce2 --- /dev/null +++ b/tests/Feature/Providers/WorkersAi/BaseUrlTest.php @@ -0,0 +1,99 @@ + Http::response(workersAiTextResponse())]); + + agent()->prompt('Hello', provider: 'workersai'); + + Http::assertSent(fn (Request $r) => $r->url() === 'https://api.cloudflare.com/client/v4/accounts/test-account-123/ai/v1/chat/completions'); +}); + +test('workersai builds gateway url when gateway config is set', function () { + configureWorkersAiProvider(accountId: 'test-account-123', gateway: 'my-gateway'); + + Http::fake(['gateway.ai.cloudflare.com/*' => Http::response(workersAiTextResponse())]); + + agent()->prompt('Hello', provider: 'workersai'); + + Http::assertSent(fn (Request $r) => $r->url() === 'https://gateway.ai.cloudflare.com/v1/test-account-123/my-gateway/workers-ai/v1/chat/completions'); +}); + +test('workersai uses explicit url when set', function () { + configureWorkersAiProvider(url: 'http://localhost:8787/v1'); + + Http::fake(['localhost:8787/*' => Http::response(workersAiTextResponse())]); + + agent()->prompt('Hello', provider: 'workersai'); + + Http::assertSent(fn (Request $r) => $r->url() === 'http://localhost:8787/v1/chat/completions'); +}); + +test('workersai explicit url overrides account_id and gateway', function () { + configureWorkersAiProvider( + accountId: 'test-account-123', + gateway: 'my-gateway', + url: 'http://custom.example.com/v1', + ); + + Http::fake(['custom.example.com/*' => Http::response(workersAiTextResponse())]); + + agent()->prompt('Hello', provider: 'workersai'); + + Http::assertSent(fn (Request $r) => $r->url() === 'http://custom.example.com/v1/chat/completions'); +}); + +test('workersai throws when account_id is missing and no url set', function () { + configureWorkersAiProvider(); + + agent()->prompt('Hello', provider: 'workersai'); +})->throws(\Laravel\Ai\Exceptions\AiException::class, 'account_id'); + +test('workersai throws when compat url used without model prefix', function () { + configureWorkersAiProvider(url: 'https://gateway.ai.cloudflare.com/v1/abc/gw/compat'); + + agent()->prompt('Hello', provider: 'workersai'); +})->throws(\Laravel\Ai\Exceptions\AiException::class, "requires the 'workers-ai/' prefix"); + +test('workersai sends bearer token in authorization header', function () { + configureWorkersAiProvider(accountId: 'test-123'); + + Http::fake(['api.cloudflare.com/*' => Http::response(workersAiTextResponse())]); + + agent()->prompt('Hello', provider: 'workersai'); + + Http::assertSent(fn (Request $r) => $r->hasHeader('Authorization', 'Bearer test-key')); +}); + +test('workersai sends session affinity header when configured', function () { + configureWorkersAiProvider(accountId: 'test-123', sessionAffinity: 'ses_abc123'); + + Http::fake(['api.cloudflare.com/*' => Http::response(workersAiTextResponse())]); + + agent()->prompt('Hello', provider: 'workersai'); + + Http::assertSent(fn (Request $r) => $r->hasHeader('x-session-affinity', 'ses_abc123')); +}); + +function configureWorkersAiProvider( + ?string $accountId = null, + ?string $gateway = null, + ?string $url = null, + ?string $sessionAffinity = null, +): void { + config(['ai.providers.workersai' => array_filter([ + 'driver' => 'workersai', + 'key' => 'test-key', + 'account_id' => $accountId, + 'gateway' => $gateway, + 'url' => $url, + 'session_affinity' => $sessionAffinity, + ])]); +} + diff --git a/tests/Feature/Providers/WorkersAi/EmbeddingTest.php b/tests/Feature/Providers/WorkersAi/EmbeddingTest.php new file mode 100644 index 000000000..d53b04d46 --- /dev/null +++ b/tests/Feature/Providers/WorkersAi/EmbeddingTest.php @@ -0,0 +1,95 @@ + [ + ...config('ai.providers.workersai'), + 'key' => 'test-key', + 'account_id' => 'test-account', + ]]); +}); + +test('embeddings request includes model and input', function () { + Http::fake(['api.cloudflare.com/*' => fakeWorkersAiEmbeddingsResponse()]); + + Embeddings::for(['Hello world'])->generate(provider: 'workersai'); + + Http::assertSent(function (Request $request) { + $body = json_decode($request->body(), true); + + return $body['model'] === '@cf/baai/bge-large-en-v1.5' + && $body['input'] === ['Hello world'] + && str_ends_with($request->url(), '/embeddings'); + }); +}); + +test('embeddings response is correctly parsed', function () { + Http::fake(['api.cloudflare.com/*' => fakeWorkersAiEmbeddingsResponse()]); + + $response = Embeddings::for(['Hello world'])->generate(provider: 'workersai'); + + expect($response->embeddings)->toHaveCount(1) + ->and($response->embeddings[0])->toHaveCount(3) + ->and($response->tokens)->toBe(10) + ->and($response->meta->provider)->toBe('workersai'); +}); + +test('multiple inputs return multiple embeddings', function () { + Http::fake(['api.cloudflare.com/*' => Http::response([ + 'object' => 'list', + 'data' => [ + ['object' => 'embedding', 'index' => 0, 'embedding' => [0.1, 0.2, 0.3]], + ['object' => 'embedding', 'index' => 1, 'embedding' => [0.4, 0.5, 0.6]], + ], + 'model' => '@cf/baai/bge-large-en-v1.5', + 'usage' => ['total_tokens' => 20], + ])]); + + $response = Embeddings::for(['Hello', 'World'])->generate(provider: 'workersai'); + + expect($response->embeddings)->toHaveCount(2); +}); + +test('embeddings request uses correct base url', function () { + Http::fake(['api.cloudflare.com/*' => fakeWorkersAiEmbeddingsResponse()]); + + Embeddings::for(['Hello'])->generate(provider: 'workersai'); + + Http::assertSent(function (Request $request) { + return $request->url() === 'https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1/embeddings'; + }); +}); + +test('embeddings request sends bearer token', function () { + Http::fake(['api.cloudflare.com/*' => fakeWorkersAiEmbeddingsResponse()]); + + Embeddings::for(['Hello'])->generate(provider: 'workersai'); + + Http::assertSent(function (Request $request) { + return $request->hasHeader('Authorization', 'Bearer test-key'); + }); +}); + +test('embeddings validates model name on compat endpoint', function () { + config(['ai.providers.workersai' => [ + ...config('ai.providers.workersai'), + 'url' => 'https://gateway.ai.cloudflare.com/v1/abc/gw/compat', + ]]); + + Embeddings::for(['Hello'])->generate(provider: 'workersai'); +})->throws(\Laravel\Ai\Exceptions\AiException::class, "requires the 'workers-ai/' prefix"); + +function fakeWorkersAiEmbeddingsResponse() +{ + return Http::response([ + 'object' => 'list', + 'data' => [ + ['object' => 'embedding', 'index' => 0, 'embedding' => [0.1, 0.2, 0.3]], + ], + 'model' => '@cf/baai/bge-large-en-v1.5', + 'usage' => ['total_tokens' => 10], + ]); +} diff --git a/tests/Feature/Providers/WorkersAi/ErrorHandlingTest.php b/tests/Feature/Providers/WorkersAi/ErrorHandlingTest.php new file mode 100644 index 000000000..74725dcbe --- /dev/null +++ b/tests/Feature/Providers/WorkersAi/ErrorHandlingTest.php @@ -0,0 +1,48 @@ + [ + ...config('ai.providers.workersai'), + 'key' => 'test-key', + 'account_id' => 'test-account', + ]]); +}); + +test('workersai throws on error response', function () { + Http::fake([ + 'api.cloudflare.com/*' => Http::response([ + 'error' => [ + 'type' => 'invalid_request_error', + 'message' => 'Model not found', + ], + ]), + ]); + + agent()->prompt('Hello', provider: 'workersai'); +})->throws(AiException::class, 'Model not found'); + +test('workersai throws on empty choices', function () { + Http::fake([ + 'api.cloudflare.com/*' => Http::response([ + 'model' => '@cf/meta/llama-3.1-8b-instruct', + 'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 0], + ]), + ]); + + agent()->prompt('Hello', provider: 'workersai'); +})->throws(AiException::class, 'did not contain any choices'); + +test('workersai throws rate limited exception on 429', function () { + Http::fake([ + 'api.cloudflare.com/*' => Http::response('Rate limited', 429), + ]); + + agent()->prompt('Hello', provider: 'workersai'); +})->throws(RateLimitedException::class); diff --git a/tests/Feature/Providers/WorkersAi/MessageMappingTest.php b/tests/Feature/Providers/WorkersAi/MessageMappingTest.php new file mode 100644 index 000000000..e87f6a551 --- /dev/null +++ b/tests/Feature/Providers/WorkersAi/MessageMappingTest.php @@ -0,0 +1,110 @@ + [ + ...config('ai.providers.workersai'), + 'key' => 'test-key', + 'account_id' => 'test-account', + ]]); +}); + +test('user message content is coerced to string', function () { + Http::fake(['api.cloudflare.com/*' => Http::response(workersAiTextResponse())]); + + (new AssistantAgent)->prompt( + 'What is Laravel?', + provider: 'workersai', + ); + + Http::assertSent(function (Request $request) { + $body = json_decode($request->body(), true); + $userMessage = collect($body['messages'])->firstWhere('role', 'user'); + + return $userMessage !== null + && is_string($userMessage['content']) + && $userMessage['content'] === 'What is Laravel?'; + }); +}); + +test('tool result follow up maps assistant and tool result messages', function () { + Http::fake([ + 'api.cloudflare.com/*' => Http::sequence([ + Http::response(fakeWorkersAiToolCallResponse()), + Http::response(workersAiTextResponse('The number is 72019')), + ]), + ]); + + (new ToolUsingAgent(fixed: true))->prompt( + 'Generate a number', + provider: 'workersai', + ); + + $recorded = Http::recorded(); + + expect($recorded)->toHaveCount(2); + + $followUpBody = json_decode($recorded[1][0]->body(), true); + $followUpMessages = $followUpBody['messages']; + + $hasAssistantWithToolCalls = false; + $hasToolResult = false; + + foreach ($followUpMessages as $msg) { + if ($msg['role'] === 'assistant' && isset($msg['tool_calls'])) { + $hasAssistantWithToolCalls = true; + } + + if ($msg['role'] === 'tool') { + $hasToolResult = true; + } + } + + expect($hasAssistantWithToolCalls)->toBeTrue() + ->and($hasToolResult)->toBeTrue(); +}); + +test('image attachment maps to image url content block', function () { + Http::fake(['api.cloudflare.com/*' => Http::response(workersAiTextResponse('I see an image'))]); + + $image = new Base64Image(base64_encode('fake-image-data'), 'image/png'); + + agent('You are helpful.')->prompt( + 'What is in this image?', + attachments: [$image], + provider: 'workersai', + ); + + Http::assertSent(function (Request $request) { + $body = json_decode($request->body(), true); + $userMessage = collect($body['messages'])->firstWhere('role', 'user'); + $content = $userMessage['content']; + + $imageBlock = collect($content)->firstWhere('type', 'image_url'); + + return $imageBlock !== null + && str_contains($imageBlock['image_url']['url'], 'image/png') + && str_contains($imageBlock['image_url']['url'], base64_encode('fake-image-data')); + }); +}); + +test('document attachments throw exception', function () { + Http::fake(['api.cloudflare.com/*' => Http::response(workersAiTextResponse())]); + + $pdf = new Base64Document(base64_encode('fake-pdf'), 'application/pdf'); + + agent('You are helpful.')->prompt( + 'What is in this PDF?', + attachments: [$pdf], + provider: 'workersai', + ); +})->throws(InvalidArgumentException::class); + diff --git a/tests/Feature/Providers/WorkersAi/ProviderOptionsTest.php b/tests/Feature/Providers/WorkersAi/ProviderOptionsTest.php new file mode 100644 index 000000000..b68d0068a --- /dev/null +++ b/tests/Feature/Providers/WorkersAi/ProviderOptionsTest.php @@ -0,0 +1,52 @@ + [ + ...config('ai.providers.workersai'), + 'key' => 'test-key', + 'account_id' => 'test-account', + ]]); +}); + +test('provider options are forwarded in request body', function () { + Http::fake(['api.cloudflare.com/*' => Http::response(workersAiTextResponse())]); + + (new ProviderOptionsAgent)->prompt('Hello', provider: 'workersai'); + + Http::assertSentCount(1); +}); + +test('session affinity is sent as header not body', function () { + config(['ai.providers.workersai.session_affinity' => 'ses_test-123']); + + Http::fake(['api.cloudflare.com/*' => Http::response(workersAiTextResponse())]); + + \Laravel\Ai\agent()->prompt('Hello', provider: 'workersai'); + + Http::assertSent(function (Request $request) { + $body = json_decode($request->body(), true); + + return $request->hasHeader('x-session-affinity', 'ses_test-123') + && ! array_key_exists('session_affinity', $body); + }); +}); + +test('provider options are preserved in tool call follow up', function () { + Http::fake([ + 'api.cloudflare.com/*' => Http::sequence([ + Http::response(fakeWorkersAiToolCallResponse()), + Http::response(workersAiTextResponse('Done')), + ]), + ]); + + (new ProviderOptionsWithToolsAgent)->prompt('Generate a number', provider: 'workersai'); + + $requests = Http::recorded(); + + expect(count($requests))->toBeGreaterThanOrEqual(2); +}); diff --git a/tests/Feature/Providers/WorkersAi/RequestMappingTest.php b/tests/Feature/Providers/WorkersAi/RequestMappingTest.php new file mode 100644 index 000000000..92e170481 --- /dev/null +++ b/tests/Feature/Providers/WorkersAi/RequestMappingTest.php @@ -0,0 +1,109 @@ + [ + ...config('ai.providers.workersai'), + 'key' => 'test-key', + 'account_id' => 'test-account', + ]]); +}); + +test('workersai sends model in request body', function () { + Http::fake(['api.cloudflare.com/*' => Http::response(workersAiTextResponse())]); + + agent()->prompt('Hello', provider: 'workersai'); + + Http::assertSent(function (Request $r) { + $body = json_decode($r->body(), true); + + return $body['model'] === '@cf/meta/llama-3.3-70b-instruct-fp8-fast'; + }); +}); + +test('workersai sends max_completion_tokens when set via attributes', function () { + Http::fake(['api.cloudflare.com/*' => Http::response(workersAiTextResponse())]); + + (new \Tests\Fixtures\Agents\AttributeAgent)->prompt('Hello', provider: 'workersai'); + + Http::assertSent(function (Request $r) { + $body = json_decode($r->body(), true); + + return data_get($body, 'max_completion_tokens') === 4096 + && ! array_key_exists('max_tokens', $body); + }); +}); + +test('workersai excludes max_completion_tokens when not set', function () { + Http::fake(['api.cloudflare.com/*' => Http::response(workersAiTextResponse())]); + + agent()->prompt('Hello', provider: 'workersai'); + + Http::assertSent(function (Request $r) { + $body = json_decode($r->body(), true); + + return ! array_key_exists('max_completion_tokens', $body) + && ! array_key_exists('max_tokens', $body); + }); +}); + +test('workersai coerces user message content to string', function () { + Http::fake(['api.cloudflare.com/*' => Http::response(workersAiTextResponse())]); + + agent()->prompt('Hello', provider: 'workersai'); + + Http::assertSent(function (Request $r) { + $body = json_decode($r->body(), true); + $userMsg = collect($body['messages'])->firstWhere('role', 'user'); + + return is_string($userMsg['content']); + }); +}); + +test('workersai sends stream_options in streaming requests', function () { + Http::fake([ + 'api.cloudflare.com/*' => Http::response( + body: $this->ssePayload([ + $this->chatChunk(['role' => 'assistant', 'content' => 'Hi']), + $this->chatChunkFinish('stop', ['prompt_tokens' => 5, 'completion_tokens' => 1]), + '[DONE]', + ]), + status: 200, + headers: ['Content-Type' => 'text/event-stream'], + ), + ]); + + $this->collectStreamEvents(); + + Http::assertSent(function (Request $r) { + $body = json_decode($r->body(), true); + + return ($body['stream'] ?? false) === true + && ($body['stream_options']['include_usage'] ?? false) === true; + }); +}); + +test('workersai sends structured output with json_schema', function () { + Http::fake(['api.cloudflare.com/*' => Http::response([ + 'id' => 'chatcmpl-123', + 'object' => 'chat.completion', + 'model' => '@cf/meta/llama-3.3-70b-instruct-fp8-fast', + 'choices' => [[ + 'index' => 0, + 'message' => [ + 'role' => 'assistant', + 'content' => '{"answer":"42"}', + ], + 'finish_reason' => 'stop', + ]], + 'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 5], + ])]); + + agent()->prompt('What is the answer?', provider: 'workersai'); + + Http::assertSentCount(1); +}); diff --git a/tests/Feature/Providers/WorkersAi/StreamingTest.php b/tests/Feature/Providers/WorkersAi/StreamingTest.php new file mode 100644 index 000000000..63cacac14 --- /dev/null +++ b/tests/Feature/Providers/WorkersAi/StreamingTest.php @@ -0,0 +1,149 @@ + [ + ...config('ai.providers.workersai'), + 'key' => 'test-key', + 'account_id' => 'test-account', + ]]); +}); + +test('streaming emits text events', function () { + Http::fake([ + 'api.cloudflare.com/*' => Http::response( + body: $this->ssePayload([ + $this->chatChunk(['role' => 'assistant', 'content' => 'Hello']), + $this->chatChunk(['content' => ' world']), + $this->chatChunkFinish('stop', ['prompt_tokens' => 10, 'completion_tokens' => 5]), + '[DONE]', + ]), + status: 200, + headers: ['Content-Type' => 'text/event-stream'], + ), + ]); + + $events = $this->collectStreamEvents(); + + expect($events[0])->toBeInstanceOf(StreamStart::class) + ->and($events[1])->toBeInstanceOf(TextStart::class) + ->and($events[2])->toBeInstanceOf(TextDelta::class)->delta->toBe('Hello') + ->and($events[3])->toBeInstanceOf(TextDelta::class)->delta->toBe(' world') + ->and($events[count($events) - 2])->toBeInstanceOf(TextEnd::class) + ->and($events[count($events) - 1])->toBeInstanceOf(StreamEnd::class); +}); + +test('streaming emits reasoning events for thinking models', function () { + Http::fake([ + 'api.cloudflare.com/*' => Http::response( + body: $this->ssePayload([ + $this->chatChunkReasoning('The user wants'), + $this->chatChunkReasoning(' me to say hello.'), + $this->chatChunk(['content' => 'Hello!']), + $this->chatChunkFinish('stop', ['prompt_tokens' => 14, 'completion_tokens' => 30]), + '[DONE]', + ]), + status: 200, + headers: ['Content-Type' => 'text/event-stream'], + ), + ]); + + $events = $this->collectStreamEvents(); + + $eventTypes = array_map(fn ($e) => get_class($e), $events); + + expect($eventTypes)->toContain(ReasoningStart::class) + ->and($eventTypes)->toContain(ReasoningDelta::class) + ->and($eventTypes)->toContain(ReasoningEnd::class) + ->and($eventTypes)->toContain(TextStart::class) + ->and($eventTypes)->toContain(TextDelta::class); + + $reasoningStartIdx = array_search(ReasoningStart::class, $eventTypes); + $textStartIdx = array_search(TextStart::class, $eventTypes); + expect($reasoningStartIdx)->toBeLessThan($textStartIdx); +}); + +test('streaming handles tool calls', function () { + Http::fake([ + 'api.cloudflare.com/*' => Http::sequence([ + Http::response( + body: $this->ssePayload([ + $this->chatChunkToolCallStart(0, 'call_1', 'FixedNumberGenerator'), + $this->chatChunkToolCallDelta(0, '{}'), + $this->chatChunkFinish('tool_calls', ['prompt_tokens' => 10, 'completion_tokens' => 5]), + '[DONE]', + ]), + status: 200, + headers: ['Content-Type' => 'text/event-stream'], + ), + Http::response( + body: $this->ssePayload([ + $this->chatChunk(['role' => 'assistant', 'content' => 'The number is 72019']), + $this->chatChunkFinish('stop', ['prompt_tokens' => 20, 'completion_tokens' => 10]), + '[DONE]', + ]), + status: 200, + headers: ['Content-Type' => 'text/event-stream'], + ), + ]), + ]); + + $events = $this->collectStreamEvents(agent: new ProviderOptionsWithToolsAgent); + + $toolCallEvents = array_values(array_filter($events, fn ($e) => $e instanceof ToolCallEvent)); + + expect($toolCallEvents)->not->toBeEmpty() + ->and($toolCallEvents[0]->toolCall->name)->toBe('FixedNumberGenerator') + ->and($toolCallEvents[0]->toolCall->id)->toBe('call_1'); +}); + +test('streaming error event stops stream', function () { + Http::fake([ + 'api.cloudflare.com/*' => Http::response( + body: $this->ssePayload([ + ['error' => ['code' => 'rate_limit_exceeded', 'message' => 'Rate limit exceeded']], + ]), + status: 200, + headers: ['Content-Type' => 'text/event-stream'], + ), + ]); + + $events = $this->collectStreamEvents(); + + expect($events)->toHaveCount(1) + ->and($events[0])->toBeInstanceOf(Error::class) + ->and($events[0]->type)->toBe('rate_limit_exceeded'); +}); + +test('streaming captures usage from final chunk', function () { + Http::fake([ + 'api.cloudflare.com/*' => Http::response( + body: $this->ssePayload([ + $this->chatChunk(['role' => 'assistant', 'content' => 'Hello']), + $this->chatChunkFinish('stop', ['prompt_tokens' => 42, 'completion_tokens' => 10]), + '[DONE]', + ]), + status: 200, + headers: ['Content-Type' => 'text/event-stream'], + ), + ]); + + $events = $this->collectStreamEvents(); + + $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + + expect($streamEnd->usage->promptTokens)->toBe(42) + ->and($streamEnd->usage->completionTokens)->toBe(10); +}); diff --git a/tests/Feature/Providers/WorkersAi/ToolCallLoopTest.php b/tests/Feature/Providers/WorkersAi/ToolCallLoopTest.php new file mode 100644 index 000000000..a23ef18d8 --- /dev/null +++ b/tests/Feature/Providers/WorkersAi/ToolCallLoopTest.php @@ -0,0 +1,47 @@ + [ + ...config('ai.providers.workersai'), + 'key' => 'test-key', + 'account_id' => 'test-account', + ]]); +}); + +test('tool calls trigger follow up request', function () { + Http::fake([ + 'api.cloudflare.com/*' => Http::sequence([ + Http::response(fakeWorkersAiToolCallResponse()), + Http::response(workersAiTextResponse('The number is 72019')), + ]), + ]); + + $response = (new ToolUsingAgent(fixed: true))->prompt( + 'Generate a number', + provider: 'workersai', + ); + + expect($response->text)->toBe('The number is 72019'); + + $recorded = Http::recorded(); + + expect($recorded)->toHaveCount(2); +}); + +test('max steps limits tool call depth', function () { + Http::fake([ + 'api.cloudflare.com/*' => Http::response(fakeWorkersAiToolCallResponse()), + ]); + + (new ToolUsingAgent(fixed: true))->prompt( + 'Generate a number', + provider: 'workersai', + ); + + $recorded = Http::recorded(); + + expect(count($recorded))->toBeLessThanOrEqual(3); +}); diff --git a/tests/Feature/Providers/WorkersAi/ToolMappingTest.php b/tests/Feature/Providers/WorkersAi/ToolMappingTest.php new file mode 100644 index 000000000..559127a6a --- /dev/null +++ b/tests/Feature/Providers/WorkersAi/ToolMappingTest.php @@ -0,0 +1,66 @@ + [ + ...config('ai.providers.workersai'), + 'key' => 'test-key', + 'account_id' => 'test-account', + ]]); +}); + +test('tool with parameters includes correct schema', function () { + Http::fake(['api.cloudflare.com/*' => Http::response(workersAiTextResponse('42'))]); + + agent(tools: [new RandomNumberGenerator])->prompt('Give me a random number', provider: 'workersai'); + + Http::assertSent(function (Request $request) { + $body = json_decode($request->body(), true); + $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); + $function = $tool['function'] ?? []; + + return $function['parameters']['type'] === 'object' + && array_key_exists('min', $function['parameters']['properties']) + && array_key_exists('max', $function['parameters']['properties']) + && in_array('min', $function['parameters']['required']) + && in_array('max', $function['parameters']['required']) + && $function['parameters']['additionalProperties'] === false; + }); +}); + +test('tool with empty schema includes parameters', function () { + Http::fake(['api.cloudflare.com/*' => Http::response(workersAiTextResponse('72019'))]); + + agent(tools: [new FixedNumberGenerator])->prompt('Give me a random number', provider: 'workersai'); + + Http::assertSent(function (Request $request) { + $body = json_decode($request->body(), true); + $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); + $function = $tool['function'] ?? []; + + return array_key_exists('parameters', $function) + && $function['parameters']['type'] === 'object' + && $function['parameters']['properties'] === [] + && $function['parameters']['required'] === [] + && $function['parameters']['additionalProperties'] === false; + }); +}); + +test('request without tools excludes tool fields', function () { + Http::fake(['api.cloudflare.com/*' => Http::response(workersAiTextResponse())]); + + agent()->prompt('Hello', provider: 'workersai'); + + Http::assertSent(function (Request $request) { + $body = json_decode($request->body(), true); + + return ! array_key_exists('tools', $body) + && ! array_key_exists('tool_choice', $body); + }); +}); diff --git a/tests/Feature/Providers/WorkersAi/WorkersAiHelpers.php b/tests/Feature/Providers/WorkersAi/WorkersAiHelpers.php new file mode 100644 index 000000000..b1ce1b50e --- /dev/null +++ b/tests/Feature/Providers/WorkersAi/WorkersAiHelpers.php @@ -0,0 +1,131 @@ +stream( + 'Hello', + provider: 'workersai', + ); + + $events = []; + + foreach ($response as $event) { + $events[] = $event; + } + + return $events; + } + + protected function ssePayload(array $events): string + { + $lines = []; + + foreach ($events as $event) { + if ($event === '[DONE]') { + $lines[] = 'data: [DONE]'; + } else { + $lines[] = 'data: '.json_encode($event); + } + } + + return implode("\n\n", $lines)."\n\n"; + } + + protected function chatChunk(array $delta, ?string $finishReason = null): array + { + return [ + 'id' => 'chatcmpl-123', + 'object' => 'chat.completion.chunk', + 'model' => '@cf/meta/llama-3.3-70b-instruct-fp8-fast', + 'choices' => [[ + 'index' => 0, + 'delta' => $delta, + 'finish_reason' => $finishReason, + ]], + ]; + } + + protected function chatChunkFinish(string $finishReason, ?array $usage = null): array + { + $chunk = [ + 'id' => 'chatcmpl-123', + 'object' => 'chat.completion.chunk', + 'model' => '@cf/meta/llama-3.3-70b-instruct-fp8-fast', + 'choices' => [[ + 'index' => 0, + 'delta' => (object) [], + 'finish_reason' => $finishReason, + ]], + ]; + + if ($usage) { + $chunk['usage'] = $usage; + } + + return $chunk; + } + + protected function chatChunkToolCallStart(int $index, string $id, string $name): array + { + return [ + 'id' => 'chatcmpl-123', + 'object' => 'chat.completion.chunk', + 'model' => '@cf/meta/llama-3.3-70b-instruct-fp8-fast', + 'choices' => [[ + 'index' => 0, + 'delta' => [ + 'tool_calls' => [[ + 'index' => $index, + 'id' => $id, + 'type' => 'function', + 'function' => ['name' => $name, 'arguments' => ''], + ]], + ], + 'finish_reason' => null, + ]], + ]; + } + + protected function chatChunkToolCallDelta(int $index, string $arguments): array + { + return [ + 'id' => 'chatcmpl-123', + 'object' => 'chat.completion.chunk', + 'model' => '@cf/meta/llama-3.3-70b-instruct-fp8-fast', + 'choices' => [[ + 'index' => 0, + 'delta' => [ + 'tool_calls' => [[ + 'index' => $index, + 'function' => ['arguments' => $arguments], + ]], + ], + 'finish_reason' => null, + ]], + ]; + } + + protected function chatChunkReasoning(string $reasoningContent): array + { + return [ + 'id' => 'chatcmpl-123', + 'object' => 'chat.completion.chunk', + 'model' => '@cf/moonshotai/kimi-k2.5', + 'choices' => [[ + 'index' => 0, + 'delta' => [ + 'reasoning_content' => $reasoningContent, + ], + 'finish_reason' => null, + ]], + ]; + } +} diff --git a/tests/Fixtures/Agents/WorkersAiAgent.php b/tests/Fixtures/Agents/WorkersAiAgent.php new file mode 100644 index 000000000..492866fcc --- /dev/null +++ b/tests/Fixtures/Agents/WorkersAiAgent.php @@ -0,0 +1,18 @@ + 'chatcmpl-123', + 'object' => 'chat.completion', + 'model' => '@cf/meta/llama-3.3-70b-instruct-fp8-fast', + 'choices' => [[ + 'index' => 0, + 'message' => [ + 'role' => 'assistant', + 'content' => $content, + ], + 'finish_reason' => 'stop', + ]], + 'usage' => [ + 'prompt_tokens' => 10, + 'completion_tokens' => 5, + ], + ]; +} + +function fakeWorkersAiToolCallResponse(): array +{ + return [ + 'id' => 'chatcmpl-tool-123', + 'object' => 'chat.completion', + 'model' => '@cf/meta/llama-3.3-70b-instruct-fp8-fast', + 'choices' => [[ + 'index' => 0, + 'message' => [ + 'role' => 'assistant', + 'content' => null, + 'tool_calls' => [[ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => 'FixedNumberGenerator', + 'arguments' => '{}', + ], + ]], + ], + 'finish_reason' => 'tool_calls', + ]], + 'usage' => [ + 'prompt_tokens' => 20, + 'completion_tokens' => 10, + ], + ]; +} + function fakeOpenAiResponse(string $text = 'Hello'): PromiseInterface { return Http::response([ diff --git a/tests/Pest.php b/tests/Pest.php index 4acda8ccf..09c850a5f 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -7,6 +7,7 @@ use Tests\Feature\Providers\Ollama\OllamaHelpers; use Tests\Feature\Providers\OpenAi\OpenAiHelpers; use Tests\Feature\Providers\OpenRouter\OpenRouterHelpers; +use Tests\Feature\Providers\WorkersAi\WorkersAiHelpers; use Tests\Feature\Providers\Xai\XaiHelpers; use Tests\TestCase; @@ -22,6 +23,7 @@ pest()->use(MistralHelpers::class)->group('provider-mistral')->in('Feature/Providers/Mistral'); pest()->use(OllamaHelpers::class)->group('provider-ollama')->in('Feature/Providers/Ollama'); pest()->use(OpenAiHelpers::class)->group('provider-openai')->in('Feature/Providers/OpenAi'); +pest()->use(WorkersAiHelpers::class)->group('provider-workersai')->in('Feature/Providers/WorkersAi'); pest()->use(XaiHelpers::class)->group('provider-xai')->in('Feature/Providers/Xai'); pest()->use(OpenRouterHelpers::class)->group('provider-openrouter')->in('Feature/Providers/OpenRouter');