CLAUDE CODE CLI를 PRISM 제공업체로 전환했습니다.

작성자

카테고리:

← 피드로
DEV Community · kevariable · 2026-07-25 개발(SW)

so here is the thing. i work on a laravel app where almost every feature touch an llm. extract structured data from a pdf, rewrite a paragraph, summarize, spell check, generate stuff. all of it go through prism, which is nice because i just write Prism::text()->using('gemini', 'gemini-2.5-flash') and forget about http clients.

but the api bill is the api bill. and i already pay for claude code subscription every month, sitting there, doing nothing when i sleep.

then i realize: claude code cli is just a binary. it take stdin, it print json to stdout. that is basically an llm api with extra steps. so why not make prism talk to it?

this post is how i did it. the whole thing is one class.

what prism actually want from a provider

prism is not magic. a provider is a class extending Prism\Prism\Providers\Provider and implementing the methods you care about. you do not need to implement everything. i only need two:

  • text(TextRequest $request): TextResponse
  • structured(StructuredRequest $request): StructuredResponse

no embeddings, no images, no streaming. if some code call ->asStream() on my provider it will blow up, and that is fine, because nothing in my app do that.

the cli contract

first, check what the binary give back:

echo "say hi" | claude -p --output-format json --model claude-haiku-4-5

Enter fullscreen mode Exit fullscreen mode

you get an envelope like this:

{
  "is_error": false,
  "result": "hi!",
  "session_id": "abc-123",
  "model": "claude-haiku-4-5",
  "usage": {
    "input_tokens": 12,
    "output_tokens": 3,
    "cache_read_input_tokens": 0
  }
}

Enter fullscreen mode Exit fullscreen mode

that is everything prism need. result is the text, usage map straight to prism Usage, session_id and model map to Meta. nice.

the provider

app/Prism/ClaudeCli/ClaudeCliProvider.php:

final class ClaudeCliProvider extends Provider
{
    public function __construct(
        private readonly string $binary,
        private readonly ?string $oauthToken,
        private readonly string $defaultModel,
        private readonly int $timeout,
    ) {}

    public function text(TextRequest $request): TextResponse
    {
        $envelope = $this->invoke(
            $this->resolveModel($request->model()),
            $this->composeSystemPrompt($request->systemPrompts()),
            $this->composeUserPrompt($request->prompt(), $request->messages()),
            $this->timeoutFor($request->clientOptions()),
        );

        return new TextResponse(
            steps: new Collection,
            text: $this->resultText($envelope),
            finishReason: FinishReason::Stop,
            toolCalls: [],
            toolResults: [],
            usage: $this->usage($envelope),
            meta: $this->meta($envelope),
            messages: new Collection,
        );
    }
}

Enter fullscreen mode Exit fullscreen mode

that is the whole shape. take the prism request, flatten it to a string, shell out, build a prism response back. no ai, just plumbing.

shelling out without shooting your foot

this is the part where you must be careful:

private function invoke(string $model, string $systemPrompt, string $userPrompt, int $timeout): array
{
    $command = [$this->binary, '-p', '--output-format', 'json', '--model', $model];

    if (trim($systemPrompt) !== '') {
        $command[] = '--append-system-prompt';
        $command[] = $systemPrompt;
    }

    $result = Process::timeout($timeout)
        ->path(sys_get_temp_dir())
        ->env($this->processEnv())
        ->input($userPrompt)
        ->run($command);

    if ($result->failed()) {
        throw PrismException::providerResponseError(
            'claude-cli exited with code '.$result->exitCode().': '.trim($result->errorOutput()),
        );
    }

    $envelope = json_decode($result->output(), true);

    if (! is_array($envelope)) {
        throw PrismException::providerResponseError('claude-cli returned non-JSON output: '.$result->output());
    }

    if (($envelope['is_error'] ?? false) === true) {
        throw PrismException::providerResponseError('claude-cli reported an error: '.($envelope['result'] ?? 'unknown'));
    }

    return $envelope;
}

Enter fullscreen mode Exit fullscreen mode

four things here are not accidents:

command as array, not string. laravel Process with an array never touch a shell. so user input that contain ; rm -rf / is just text. if you build the command as one string you are one paste away from rce. do not do it.

user prompt go through stdin, not argv. prompts get long. argv have a size limit, stdin does not. also stdin is not visible in ps aux.

->path(sys_get_temp_dir()). claude cli read CLAUDE.md, .claude/settings.json, hooks, all from the working directory. if you run it inside your laravel project, your own project instructions leak into every user request. run it from an empty temp dir instead.

is_error check. cli can exit 0 and still say the request failed inside the envelope. exit code alone is not enough.

for auth i pass the subscription oauth token as env:

private function processEnv(): array
{
    $env = ['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' => '1'];

    if ($this->oauthToken !== null && $this->oauthToken !== '') {
        $env['CLAUDE_CODE_OAUTH_TOKEN'] = $this->oauthToken;
    }

    return $env;
}

Enter fullscreen mode Exit fullscreen mode

no api key anywhere. it use the subscription.

structured output when the provider have no structured mode

this is the fun part. gemini and openai have native json schema mode. the cli does not. so i fake it: take the prism schema, dump it into the prompt, then be paranoid when parsing.

public function structured(StructuredRequest $request): StructuredResponse
{
    $userPrompt = $this->composeUserPrompt($request->prompt(), $request->messages())
        .$this->schemaInstruction($request->schema()->toArray());

    $envelope = $this->invoke(/* ... */);
    $text = $this->resultText($envelope);

    return new StructuredResponse(
        steps: new Collection,
        text: $text,
        structured: $this->decodeJson($text),
        finishReason: FinishReason::Stop,
        usage: $this->usage($envelope),
        meta: $this->meta($envelope),
    );
}

private function schemaInstruction(array $schema): string
{
    return "\n\nYou must respond with ONLY a single valid JSON object that conforms to the JSON schema below. "
        .'Do not include markdown code fences, comments, or any prose before or after the JSON.'
        ."\n\nJSON schema:\n".json_encode($schema, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}

Enter fullscreen mode Exit fullscreen mode

the caller side never change. they still write ->withSchema($schema)->asStructured() and get an array. the hack live only inside the provider, which is exactly where a hack should live.

and the parser, three levels of giving up:

private function decodeJson(string $text): array
{
    $candidate = trim($text);

    if (preg_match('/```

(?:json)?\s*(.+?)\s*

```/s', $candidate, $matches) === 1) {
        $candidate = $matches[1];
    }

    $decoded = json_decode($candidate, true);

    if (! is_array($decoded)) {
        $start = strpos($candidate, '{');
        $end = strrpos($candidate, '}');

        if ($start !== false && $end !== false && $end > $start) {
            $decoded = json_decode(substr($candidate, $start, $end - $start + 1), true);
        }
    }

    if (! is_array($decoded)) {
        throw PrismException::providerResponseError('claude-cli did not return valid JSON: '.$text);
    }

    return $decoded;
}

Enter fullscreen mode Exit fullscreen mode

first try raw. then strip code fences, because models love fences even when you say no fences. then cut from first { to last }, because sometimes you get “Sure! Here is the JSON:” in front. after that, give up loudly.

register it

prism use a manager, so extend() is all you need. do it in the boot of a service provider:

public function boot(): void
{
    $this->app->make(PrismManager::class)->extend('claude-cli', fn (): ClaudeCliProvider => new ClaudeCliProvider(
        binary: config('prism.claude_cli.binary'),
        oauthToken: config('prism.claude_cli.oauth_token'),
        defaultModel: config('prism.claude_cli.model'),
        timeout: (int) config('prism.claude_cli.timeout'),
    ));
}

Enter fullscreen mode Exit fullscreen mode

config in config/prism.php:

'claude_cli' => [
    'binary' => env('CLAUDE_CLI_BINARY', 'claude'),
    'oauth_token' => env('CLAUDE_CODE_OAUTH_TOKEN'),
    'model' => env('CLAUDE_CLI_MODEL', 'claude-haiku-4-5'),
    'timeout' => (int) env('CLAUDE_CLI_TIMEOUT', 120),
],

Enter fullscreen mode Exit fullscreen mode

note the closure. under octane the container stay alive between request, so never resolve config in a constructor you register as singleton. closure is resolved per make, so it is safe.

the payoff: swap provider with one env var

every ai call in the app already look like this:

Prism::structured()
    ->using(config('prism.text.provider'), config('prism.text.model'))
    ->withSchema($schema)
    ->withPrompt($prompt)
    ->asStructured();

Enter fullscreen mode Exit fullscreen mode

so switching from gemini to the subscription is:

PRISM_TEXT_PROVIDER=claude-cli
PRISM_TEXT_MODEL=claude-haiku-4-5

Enter fullscreen mode Exit fullscreen mode

and rollback is flipping it back. no code deploy. this is the actual reason i use prism, not the fluent api.

resolveModel also protect me from myself:

private function resolveModel(string $requested): string
{
    return str_starts_with($requested, 'claude') ? $requested : $this->defaultModel;
}

Enter fullscreen mode Exit fullscreen mode

if some old code still pass gemini-2.5-flash to this provider, the cli would explode. instead it fall back to the configured default. small thing, save me one production incident already.

things that bite me

timeout chain. cli call is slow. like 30 to 90 second slow for a big prompt. you have a timeout in the provider, a timeout in the queue worker, a timeout in php-fpm, a timeout in nginx. the innermost one must be the smallest, and every outer one must be bigger. i learn this the hard way when request keep dying with no error in the log.

cli must exist in the container. it is a node binary. your php docker image probably do not have node. either install it in the image, or keep an api provider as fallback.

concurrency. each call is a real process with real memory. api call is cheap to fan out, process is not. if you run twenty in parallel your box will feel it.

no streaming, no tools. i do not implement them, so features that need token by token streaming still use the api provider. mixing is fine, that is the whole point of config per feature.

should you do this

if you are a solo dev or a small product and you already pay for the subscription, yes. it is one class, maybe 200 line, and it cut the inference cost to zero for the features where latency does not matter.

if you serve real traffic at scale, no. process spawning is not a load bearing architecture. use it as a canary, keep the api provider one env var away.

the bigger lesson is not about claude cli anyway. prism extend() mean anything that can take text and return text can become a provider. a local ollama, an internal company gateway, a mock that return fixture in test. you write one class, and the rest of your app never know the difference.

that is the good part.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다