> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# AI SDK의 커스텀 프로바이더 만들기

> Laravel AI SDK의 소스 코드를 읽어 나가며, 기본 제공되지 않는 AI 서비스에 대응하는 커스텀 프로바이더를 구현하는 방법을 해설합니다.

## 커스텀 프로바이더가 필요한 장면

Laravel AI SDK는 OpenAI, Anthropic, Gemini, Mistral 등 주요 AI 서비스를 기본으로 서포트하고 있습니다. 그러나 이하와 같은 케이스에서는 표준 프로바이더로는 대응할 수 없습니다.

* 아직 공식 대응되지 않은 신흥 AI 서비스
* 사내의 모델 게이트웨이나 과금 관리 레이어를 경유시키고 싶음
* 독자 프로토콜이나 인증 방식을 가진 온프레미스 추론 서버

이러한 경우에, 커스텀 프로바이더를 구현하여 SDK의 `AiManager`에 등록함으로써, 표준 프로바이더와 같은 API로 이용할 수 있습니다.

<Info>
  **OpenAI 호환 API의 경우에는 내장 드라이버를 사용한다**

  SDK 0.9 이후, `openai-compatible` 드라이버가 기본으로 제공되고 있습니다. 사내의 OpenAI 호환 추론 서버(Ollama의 OpenAI 호환 엔드포인트 등)에는 `config/ai.php`에 설정을 추가하는 것만으로 이용할 수 있으며, 커스텀 프로바이더의 구현은 불필요합니다.

  ```php theme={null}
  'my-server' => [
      'driver' => 'openai-compatible',
      'key'    => env('MY_SERVER_API_KEY'),
      'url'    => env('MY_SERVER_URL', 'http://localhost:8080/v1'),
      'models' => [
          'text' => ['default' => 'llama3.3-70b'],
      ],
  ],
  ```
</Info>

## 아키텍처의 개요

### 2층 구조

Laravel AI SDK는 프로바이더와 게이트웨이의 2층으로 구성되어 있습니다.

| 레이어          | 역할                          | 예                                             |
| ------------ | --------------------------- | --------------------------------------------- |
| **Provider** | 앱 측의 인터페이스. 모델명의 해결, 설정의 보관 | `OpenAiProvider`, `AnthropicProvider`         |
| **Gateway**  | 실제 API 요청을 1스텝분 송신          | `AnthropicGateway`, `OpenAiCompatibleGateway` |

모든 프로바이더는 추상 클래스 `Laravel\Ai\Providers\Provider`를 상속하고, 기능별의 컨트랙트(인터페이스)를 구현합니다. 도구 호출을 포함한 멀티 스텝의 루프는 `TextGenerationLoop`가 게이트웨이를 반복 호출함으로써 실현됩니다.

### 컨트랙트 목록

제공하고 싶은 기능에 따라 필요한 컨트랙트만을 구현합니다.

| 컨트랙트                    | 네임스페이스                                                 | 기능          |
| ----------------------- | ------------------------------------------------------ | ----------- |
| `TextProvider`          | `Laravel\Ai\Contracts\Providers\TextProvider`          | 텍스트 생성·에이전트 |
| `EmbeddingProvider`     | `Laravel\Ai\Contracts\Providers\EmbeddingProvider`     | 벡터 임베딩 생성   |
| `ImageProvider`         | `Laravel\Ai\Contracts\Providers\ImageProvider`         | 이미지 생성      |
| `AudioProvider`         | `Laravel\Ai\Contracts\Providers\AudioProvider`         | 음성 합성(TTS)  |
| `TranscriptionProvider` | `Laravel\Ai\Contracts\Providers\TranscriptionProvider` | 음성 인식(STT)  |

<Info>
  대부분의 경우 `TextProvider`만 구현하면 충분합니다.
</Info>

## TextProvider 컨트랙트

텍스트 생성 프로바이더가 구현하는 인터페이스입니다(`src/Contracts/Providers/TextProvider.php`).

```php theme={null}
interface TextProvider extends Provider
{
    public function prompt(AgentPrompt $prompt): AgentResponse;
    public function stream(AgentPrompt $prompt): StreamableAgentResponse;
    public function useTextGateway(StepTextGateway $gateway): self;
    public function textGenerationLoop(): TextGenerationLoop;
    public function defaultTextModel(): string;
    public function cheapestTextModel(): string;
    public function smartestTextModel(): string;
}
```

`prompt()`와 `stream()`의 구현은 기존의 트레이트(`GeneratesText`, `StreamsText`)에 맡길 수 있으므로, 실제로 구현이 필요한 것은 모델명을 반환하는 3개의 메서드와 `textGateway()`의 합계 4개입니다. 멀티 스텝의 도구 루프는 `TextGenerationLoop`가 관리하므로, 게이트웨이는 1스텝분의 요청만을 처리합니다.

## 구현 예: 커스텀 프로바이더

독자 API를 가진 추론 서비스를 `my-inference`라는 프로바이더로서 등록하는 예입니다.

<Steps>
  <Step title="프로바이더 클래스를 작성한다">
    `app/Ai/Providers/MyInferenceProvider.php`를 작성합니다.

    ```php theme={null}
    <?php

    declare(strict_types=1);

    namespace App\Ai\Providers;

    use Illuminate\Contracts\Events\Dispatcher;
    use Laravel\Ai\Contracts\Gateway\StepTextGateway;
    use Laravel\Ai\Contracts\Providers\TextProvider;
    use Laravel\Ai\Providers\Concerns\GeneratesText;
    use Laravel\Ai\Providers\Concerns\HasTextGateway;
    use Laravel\Ai\Providers\Concerns\StreamsText;
    use Laravel\Ai\Providers\Provider;

    class MyInferenceProvider extends Provider implements TextProvider
    {
        use GeneratesText;
        use HasTextGateway;
        use StreamsText;

        public function __construct(protected array $config, protected Dispatcher $events)
        {
            //
        }

        /**
         * Get the credentials for the provider.
         */
        public function providerCredentials(): array
        {
            return ['key' => $this->config['key'] ?? null];
        }

        /**
         * Get the provider's text gateway.
         */
        public function textGateway(): StepTextGateway
        {
            return $this->textGateway ??= new \App\Ai\Gateway\MyInferenceGateway($this->events);
        }

        public function defaultTextModel(): string
        {
            return $this->config['models']['text']['default'] ?? 'my-model-v1';
        }

        public function cheapestTextModel(): string
        {
            return $this->config['models']['text']['cheapest'] ?? 'my-model-v1';
        }

        public function smartestTextModel(): string
        {
            return $this->config['models']['text']['smartest'] ?? 'my-model-v1';
        }
    }
    ```
  </Step>

  <Step title="AppServiceProvider에 등록한다">
    `App\Providers\AppServiceProvider`의 `boot` 메서드에서 `extend()`를 사용해 등록합니다.

    ```php theme={null}
    <?php

    namespace App\Providers;

    use App\Ai\Providers\MyInferenceProvider;
    use Illuminate\Contracts\Events\Dispatcher;
    use Illuminate\Support\ServiceProvider;
    use Laravel\Ai\AiManager;

    class AppServiceProvider extends ServiceProvider
    {
        public function boot(): void
        {
            $this->app->make(AiManager::class)->extend(
                'my-inference',
                fn (array $config) => new MyInferenceProvider(
                    $config,
                    $this->app->make(Dispatcher::class)
                )
            );
        }
    }
    ```
  </Step>

  <Step title="config/ai.php에 프로바이더를 추가한다">
    ```php theme={null}
    'providers' => [
        // ...既存のプロバイダー...

        'my-inference' => [
            'driver' => 'my-inference',
            'key'    => env('MY_INFERENCE_API_KEY'),
            'url'    => env('MY_INFERENCE_URL', 'http://localhost:8080'),
        ],
    ],
    ```

    `.env`에도 추가합니다.

    ```ini theme={null}
    MY_INFERENCE_API_KEY=your-api-key
    MY_INFERENCE_URL=https://inference.example.internal
    ```
  </Step>

  <Step title="에이전트에서 사용한다">
    등록 후에는 `prompt()`의 `provider` 인수에 프로바이더명을 지정하는 것만으로 표준 프로바이더와 같은 식으로 사용할 수 있습니다.

    ```php theme={null}
    use App\Ai\Agents\SummaryAgent;

    $response = SummaryAgent::make()->prompt('この記事を要約してください。', provider: 'my-inference');

    echo $response->text;
    ```

    기본 프로바이더로서 사용하는 경우에는 `config/ai.php`의 `default` 키를 변경합니다.

    ```php theme={null}
    'default' => 'my-inference',
    ```
  </Step>
</Steps>

## 커스텀 게이트웨이의 구현

OpenAI 호환이 아닌 독자 API를 가진 서비스에는, `StepTextGateway` 컨트랙트를 구현한 커스텀 게이트웨이가 필요합니다.

### StepTextGateway 컨트랙트

`src/Contracts/Gateway/StepTextGateway.php`가 정의하는 인터페이스입니다. 게이트웨이는 대화의 **1스텝분**의 요청을 처리하고, `StepResponse`를 반환합니다. 도구 호출의 루프는 호출자의 `TextGenerationLoop`가 관리합니다.

```php theme={null}
interface StepTextGateway
{
    public function generateTextStep(
        TextProvider $provider,
        string $model,
        ?string $instructions,
        array $messages,
        array $tools,
        ?array $schema,
        ?TextGenerationOptions $options,
        ?int $timeout,
        StepContext $stepContext,
    ): StepResponse;

    public function generateStreamStep(
        string $invocationId,
        TextProvider $provider,
        string $model,
        ?string $instructions,
        array $messages,
        array $tools,
        ?array $schema,
        ?TextGenerationOptions $options,
        ?int $timeout,
        StepContext $stepContext,
    ): Generator;
}
```

<Warning>
  0.8 이전의 `TextGateway` 컨트랙트(`generateText()`, `stream()`, `onToolInvocation()`)는 0.9에서 삭제되었습니다. 커스텀 게이트웨이를 가지고 있는 경우에는 `StepTextGateway`로의 이관이 필요합니다. 도구 호출의 `onToolInvocation()`은 `TextGenerationLoop`로 이동되어 있습니다.
</Warning>

### 커스텀 게이트웨이의 구현 예

독자의 추론 API에 HTTP 요청을 보내는 심플한 게이트웨이의 골격입니다.

```php theme={null}
<?php

declare(strict_types=1);

namespace App\Ai\Gateway;

use Generator;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Facades\Http;
use Laravel\Ai\Contracts\Gateway\StepTextGateway;
use Laravel\Ai\Contracts\Providers\TextProvider;
use Laravel\Ai\Gateway\StepContext;
use Laravel\Ai\Gateway\StepResponse;
use Laravel\Ai\Gateway\TextGenerationOptions;
use Laravel\Ai\Responses\Data\FinishReason;
use Laravel\Ai\Responses\Data\Meta;
use Laravel\Ai\Responses\Data\Usage;

class MyInferenceGateway implements StepTextGateway
{
    public function __construct(protected Dispatcher $events) {}

    public function generateTextStep(
        TextProvider $provider,
        string $model,
        ?string $instructions,
        array $messages,
        array $tools,
        ?array $schema,
        ?TextGenerationOptions $options,
        ?int $timeout,
        StepContext $stepContext,
    ): StepResponse {
        $credentials = $provider->providerCredentials();
        $config      = $provider->additionalConfiguration();

        $response = Http::withToken($credentials['key'])
            ->baseUrl($config['url'])
            ->timeout($timeout ?? 30)
            ->post('/generate', [
                'model'        => $model,
                'instructions' => $instructions,
                'messages'     => $this->formatMessages($messages),
            ]);

        $data = $response->json();

        return new StepResponse(
            text: $data['output']['text'] ?? '',
            toolCalls: [],
            finishReason: FinishReason::Stop,
            usage: new Usage(
                promptTokens: $data['usage']['input_tokens'] ?? 0,
                completionTokens: $data['usage']['output_tokens'] ?? 0,
            ),
            meta: new Meta(
                provider: $provider->name(),
                model: $model,
            ),
        );
    }

    public function generateStreamStep(
        string $invocationId,
        TextProvider $provider,
        string $model,
        ?string $instructions,
        array $messages,
        array $tools,
        ?array $schema,
        ?TextGenerationOptions $options,
        ?int $timeout,
        StepContext $stepContext,
    ): Generator {
        // ストリーミングの実装（省略）
        yield from [];

        return null;
    }

    protected function formatMessages(array $messages): array
    {
        return array_map(fn ($message) => [
            'role'    => $message->role->value,
            'content' => $message->content,
        ], $messages);
    }
}
```

<Info>
  도구 호출(function calling)을 서포트하는 경우에는 `generateTextStep()`에서 도구의 호출 결과를 `toolCalls`에 포함하고, `finishReason`을 `FinishReason::ToolCalls`로 합니다. 도구의 실행과 다음 스텝으로의 이행은 `TextGenerationLoop`가 자동으로 처리합니다. 구현의 참고에는 `AnthropicGateway.php`를 참조해 주세요.
</Info>

## 테스트 방법

### 에이전트 클래스의 fake()를 사용한다

커스텀 프로바이더를 사용하는 에이전트의 테스트에는, 에이전트 클래스의 `fake()` 메서드를 사용합니다. 커스텀 프로바이더인지 여부와 관계없이, 페이크 게이트웨이가 프로바이더에 세팅됩니다.

```php theme={null}
<?php

namespace Tests\Feature;

use App\Ai\Agents\SummaryAgent;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class SummaryAgentTest extends TestCase
{
    use RefreshDatabase;

    public function test_summary_agent_returns_text(): void
    {
        SummaryAgent::fake(['これは要約です。']);

        $response = SummaryAgent::make()->prompt('長い記事のテキスト...', provider: 'my-inference');

        $this->assertEquals('これは要約です。', $response->text);

        SummaryAgent::assertPrompted('長い記事のテキスト...');
    }
}
```

<Info>
  0.9 이후, `Agent::fake()`의 응답은 실제 프로바이더와 같은 `TextGenerationLoop`를 거칩니다. 도구를 등록하지 않은 에이전트에서 페이크의 도구 호출을 설정한 경우, `NoSuchToolException`이 스로우됩니다.
</Info>

### extend()를 사용한 모크 프로바이더

`extend()`를 사용하여, 테스트용의 프로바이더를 컨테이너에서 등록할 수도 있습니다.

```php theme={null}
public function test_with_mock_provider(): void
{
    $this->app->make(AiManager::class)->extend(
        'my-inference',
        function (array $config) {
            $events = $this->app->make(\Illuminate\Contracts\Events\Dispatcher::class);

            return new MyInferenceProvider($config, $events);
        }
    );

    // テストコード
}
```

## 참고 링크

<Card title="OllamaProvider.php — 심플한 프로바이더 구현 예" icon="github" href="https://github.com/laravel/ai/blob/0.x/src/Providers/OllamaProvider.php">
  로컬 모델 서버에 접속하는 프로바이더의 최소 구성입니다. 커스텀 프로바이더 구현의 참고가 됩니다.
</Card>

<Card title="AnthropicGateway.php — 게이트웨이의 구현 예" icon="github" href="https://github.com/laravel/ai/blob/0.x/src/Gateway/Anthropic/AnthropicGateway.php">
  `StepTextGateway`를 구현한 게이트웨이의 구현 예입니다. `generateTextStep()`과 `generateStreamStep()`의 구현을 확인할 수 있습니다.
</Card>

<Card title="StepTextGateway Contract" icon="github" href="https://github.com/laravel/ai/blob/0.x/src/Contracts/Gateway/StepTextGateway.php">
  텍스트 생성 게이트웨이가 구현하는 인터페이스의 정의입니다.
</Card>

<Card title="TextProvider Contract" icon="github" href="https://github.com/laravel/ai/blob/0.x/src/Contracts/Providers/TextProvider.php">
  텍스트 생성 프로바이더가 구현하는 인터페이스의 정의입니다.
</Card>


## Related topics

- [커스텀 프로바이더](/ko/packages/laravel-copilot-sdk/custom-providers.md)
- [서비스 프로바이더](/ko/service-providers.md)
- [Laravel Socialite (소셜 인증)](/ko/socialite.md)
- [Telemetry](/ko/packages/laravel-copilot-sdk/telemetry.md)
- [Boost의 커스텀 에이전트 만들기](/ko/advanced/boost-custom-agent.md)
