From 17758d5ecd82013d8bd42a7f51d5795fc6f2acce Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 7 Jun 2026 19:56:46 -0400 Subject: [PATCH 1/6] add Claude Code provider plugin --- README.md | 20 ++ .../ai-provider-for-claude-code/README.md | 27 ++ .../ai-provider-for-claude-code.php | 49 ++++ .../ClaudeCodeModelMetadataDirectory.php | 75 +++++ .../src/Provider/ClaudeCodeProvider.php | 72 +++++ .../ClaudeCodeProviderAvailability.php | 37 +++ .../ClaudeCodeTextGenerationModel.php | 153 +++++++++++ .../src/Runtime/ClaudeCodeProcess.php | 257 ++++++++++++++++++ .../src/autoload.php | 20 ++ lib/agents-md-guidance.sh | 2 + lib/carried-plugins.sh | 59 ++++ lib/homeboy.sh | 2 +- setup.sh | 3 +- tests/carried-claude-code-plugin.sh | 49 ++++ upgrade.sh | 9 +- 15 files changed, 829 insertions(+), 5 deletions(-) create mode 100644 carried-plugins/ai-provider-for-claude-code/README.md create mode 100644 carried-plugins/ai-provider-for-claude-code/ai-provider-for-claude-code.php create mode 100644 carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeModelMetadataDirectory.php create mode 100644 carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProvider.php create mode 100644 carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProviderAvailability.php create mode 100644 carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php create mode 100644 carried-plugins/ai-provider-for-claude-code/src/Runtime/ClaudeCodeProcess.php create mode 100644 carried-plugins/ai-provider-for-claude-code/src/autoload.php create mode 100644 lib/carried-plugins.sh create mode 100644 tests/carried-claude-code-plugin.sh diff --git a/README.md b/README.md index 70129ca..be9b6ca 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,7 @@ SITE_DOMAIN=example.com ./setup.sh --dry-run | **[OpenCode](https://opencode.ai)**, **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)**, or **[Studio Code](https://developer.wordpress.com/studio/)** | AI coding agent runtime | Selected via `--runtime` | | **[Data Machine](https://github.com/Extra-Chill/data-machine)** | Memory (SOUL/USER/MEMORY.md), self-scheduling, AI tools, Agent Ping | No — wp-coding-agents composes on top of DM | | **[Data Machine Code](https://github.com/Extra-Chill/data-machine-code)** | Workspace management, GitHub integration, git operations | Installed with Data Machine | +| **AI Provider for Claude Code** | wp-coding-agents-carried WP AI Client provider backed by the local Claude Code CLI session | Installed when Claude Code is selected or detected | | **[Homeboy](https://github.com/Extra-Chill/homeboy)** | Optional developer power layer for project status, component-aware checks, review loops, and WordPress extension verification | `--with-homeboy` | | **[Kimaki](https://kimaki.xyz)**, **[cc-connect](https://github.com/nichochar/cc-connect)**, or **[opencode-telegram](https://github.com/grinev/opencode-telegram-bot)** | Chat bridge (Discord, multi-platform, or Telegram) | `--no-chat` | | **SessionStart hook** | Syncs Data Machine agents into CLAUDE.md on every session (Claude Code and Studio Code) | Always installed | @@ -247,6 +248,25 @@ Data Machine is the substrate wp-coding-agents composes on top of — memory, sc ## Optional Homeboy Layer +### Claude Code Provider + +When the selected or detected runtime includes Claude Code, wp-coding-agents syncs and activates a carried plugin at: + +``` +wp-content/plugins/ai-provider-for-claude-code +``` + +This plugin registers WP AI Client provider id `claude-code`. It is backed by the locally authenticated `claude` CLI available to the host or Codebox sandbox. It is not an official Anthropic API provider, does not use an Anthropic API key, and does not require WP AI Gateway for Homeboy Codebox cooking. + +Use WP AI Gateway only when an external client needs an OpenAI-compatible WordPress endpoint. Homeboy Codebox tasks can select the provider directly with `--provider claude-code` and pass the carried provider plugin path through the provider config. + +Configuration: + +- `AI_PROVIDER_CLAUDE_CODE_BIN` overrides the Claude Code binary path/name. +- `AI_PROVIDER_CLAUDE_CODE_TIMEOUT` sets the default process timeout in seconds. +- Model custom option `cwd` sets the process working directory. +- Model custom option `args` appends additional Claude Code CLI arguments. + `--with-homeboy` adds Homeboy as an optional, recommended developer power layer. Homeboy is not bundled or vendorized by wp-coding-agents; setup verifies or installs the external Homeboy CLI, verifies the WordPress extension, then exposes its availability to Data Machine Code so composed agent instructions can include Homeboy workflows. When Homeboy is available, the composed `AGENTS.md` Homeboy Codebox section is the canonical guidance for repo-aware coding fan-out: use `homeboy agent-task` plans/runs with WP Codebox sandboxes for isolated tasks. Without Homeboy, agents should continue using Data Machine Code worktrees, normal git review, and the selected chat or terminal runtime; do not assume Homeboy commands exist. diff --git a/carried-plugins/ai-provider-for-claude-code/README.md b/carried-plugins/ai-provider-for-claude-code/README.md new file mode 100644 index 0000000..3b825bc --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/README.md @@ -0,0 +1,27 @@ +# AI Provider for Claude Code + +`ai-provider-for-claude-code` is a wp-coding-agents-carried WP AI Client provider backed by the locally authenticated Claude Code CLI. + +It is not an official Anthropic API provider. It does not use an Anthropic API key and does not implement the Anthropic Messages API. Requests are executed by the `claude` binary available to the WordPress host or Codebox sandbox. + +## Provider + +- Provider ID: `claude-code` +- Models: `claude-code`, `sonnet`, `opus`, `haiku` +- Auth: existing Claude Code CLI session on the host/sandbox + +`claude-code` uses the CLI default model. The named models pass `--model ` to Claude Code. + +## Configuration + +- `AI_PROVIDER_CLAUDE_CODE_BIN`: override the Claude Code binary path/name. Defaults to `claude`. +- `AI_PROVIDER_CLAUDE_CODE_TIMEOUT`: process timeout in seconds. Defaults to `600`. +- `AI_PROVIDER_CLAUDE_CODE_BIN` constant: WordPress constant alternative for the binary path/name. +- `ai_provider_claude_code_bin` filter: override the binary path/name. +- `ai_provider_claude_code_env` filter: return environment variables for the Claude Code process. + +Model custom options: + +- `cwd`: process working directory. +- `timeout`: per-request process timeout in seconds. +- `args`: additional CLI args appended to the `claude -p` command. diff --git a/carried-plugins/ai-provider-for-claude-code/ai-provider-for-claude-code.php b/carried-plugins/ai-provider-for-claude-code/ai-provider-for-claude-code.php new file mode 100644 index 0000000..ee24ef9 --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/ai-provider-for-claude-code.php @@ -0,0 +1,49 @@ +hasProvider(ClaudeCodeProvider::class)) { + $registry->registerProvider(ClaudeCodeProvider::class); + } +} + +add_action('init', __NAMESPACE__ . '\\register_provider', 5); diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeModelMetadataDirectory.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeModelMetadataDirectory.php new file mode 100644 index 0000000..9d9e785 --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeModelMetadataDirectory.php @@ -0,0 +1,75 @@ +getModelMap()); + } + + /** + * {@inheritDoc} + */ + public function hasModelMetadata(string $modelId): bool + { + return isset($this->getModelMap()[$modelId]); + } + + /** + * {@inheritDoc} + */ + public function getModelMetadata(string $modelId): ModelMetadata + { + $models = $this->getModelMap(); + if (!isset($models[$modelId])) { + throw new InvalidArgumentException('No Claude Code model with the requested ID was found.'); + } + + return $models[$modelId]; + } + + /** + * Gets the supported Claude Code model map. + * + * @return array Model metadata keyed by ID. + */ + private function getModelMap(): array + { + $options = [ + new SupportedOption(OptionEnum::systemInstruction()), + new SupportedOption(OptionEnum::customOptions()), + new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]), + new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::text()]]), + ]; + + $models = []; + foreach (['claude-code', 'sonnet', 'opus', 'haiku'] as $modelId) { + $models[$modelId] = new ModelMetadata( + $modelId, + $modelId, + [CapabilityEnum::textGeneration(), CapabilityEnum::chatHistory()], + $options + ); + } + + return $models; + } +} diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProvider.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProvider.php new file mode 100644 index 0000000..1417a4e --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProvider.php @@ -0,0 +1,72 @@ +getSupportedCapabilities() as $capability) { + if ($capability->isTextGeneration()) { + return new ClaudeCodeTextGenerationModel( + $modelMetadata, + $providerMetadata, + new ClaudeCodeProcess() + ); + } + } + + throw new RuntimeException('Unsupported Claude Code model capabilities.'); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderMetadata(): ProviderMetadata + { + return new ProviderMetadata( + 'claude-code', + 'Claude Code', + ProviderTypeEnum::server(), + 'https://docs.anthropic.com/en/docs/claude-code', + null, + 'Local Claude Code CLI access using the host or sandbox authenticated session.' + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface + { + return new ClaudeCodeProviderAvailability(new ClaudeCodeProcess()); + } + + /** + * {@inheritDoc} + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface + { + return new ClaudeCodeModelMetadataDirectory(); + } +} diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProviderAvailability.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProviderAvailability.php new file mode 100644 index 0000000..1980576 --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProviderAvailability.php @@ -0,0 +1,37 @@ +process = $process; + } + + /** + * {@inheritDoc} + */ + public function isConfigured(): bool + { + return $this->process->isAvailable(); + } +} diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php new file mode 100644 index 0000000..d15deea --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php @@ -0,0 +1,153 @@ +metadata = $metadata; + $this->providerMetadata = $providerMetadata; + $this->process = $process; + $this->config = ModelConfig::fromArray([]); + } + + /** + * {@inheritDoc} + */ + public function metadata(): ModelMetadata + { + return $this->metadata; + } + + /** + * {@inheritDoc} + */ + public function providerMetadata(): ProviderMetadata + { + return $this->providerMetadata; + } + + /** + * {@inheritDoc} + */ + public function setConfig(ModelConfig $config): void + { + $this->config = $config; + } + + /** + * {@inheritDoc} + */ + public function getConfig(): ModelConfig + { + return $this->config; + } + + /** + * {@inheritDoc} + */ + public function generateTextResult(array $prompt): GenerativeAiResult + { + $customOptions = $this->config->getCustomOptions(); + $result = $this->process->run( + $this->preparePrompt($prompt), + $this->metadata->getId(), + $customOptions + ); + + return new GenerativeAiResult( + $result['id'], + [new Candidate(new ModelMessage([new MessagePart($result['text'])]), FinishReasonEnum::stop())], + new TokenUsage(0, 0, 0), + $this->providerMetadata, + $this->metadata, + [ + 'command' => 'claude-code', + 'exit_code' => $result['exit_code'], + ] + ); + } + + /** + * Converts WP AI Client messages to a Claude Code prompt string. + * + * @param list $messages Prompt messages. + * @return string Prompt string. + */ + private function preparePrompt(array $messages): string + { + $chunks = []; + $systemInstruction = $this->config->getSystemInstruction(); + if ($systemInstruction) { + $chunks[] = "System:\n" . $systemInstruction; + } + + foreach ($messages as $message) { + $parts = []; + foreach ($message->getParts() as $part) { + if (!$part->getType()->isText()) { + throw new InvalidArgumentException( + 'Claude Code text generation currently supports text message parts only.' + ); + } + $parts[] = $part->getText(); + } + + $role = $message->getRole()->isModel() ? 'Assistant' : 'User'; + $chunks[] = $role . ":\n" . implode("\n", $parts); + } + + return implode("\n\n", $chunks); + } +} diff --git a/carried-plugins/ai-provider-for-claude-code/src/Runtime/ClaudeCodeProcess.php b/carried-plugins/ai-provider-for-claude-code/src/Runtime/ClaudeCodeProcess.php new file mode 100644 index 0000000..ff9cb82 --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/src/Runtime/ClaudeCodeProcess.php @@ -0,0 +1,257 @@ +binary(); + if (strpos($binary, '/') !== false) { + return is_executable($binary); + } + + $command = 'command -v ' . escapeshellarg($binary) . ' >/dev/null 2>&1'; + $process = proc_open($command, [], $pipes); + if (!is_resource($process)) { + return false; + } + + return proc_close($process) === 0; + } + + /** + * Runs Claude Code for a prompt. + * + * @param string $prompt Prompt text. + * @param string $modelId Model ID. + * @param array $customOptions Custom model options. + * @return array{id:string,text:string,exit_code:int} + */ + public function run(string $prompt, string $modelId, array $customOptions = []): array + { + $timeout = $this->timeout($customOptions); + $command = [ + $this->binary(), + '-p', + $prompt, + '--output-format', + 'text', + ]; + + if ($modelId !== 'claude-code') { + $command[] = '--model'; + $command[] = $modelId; + } + + foreach ($this->extraArgs($customOptions) as $arg) { + $command[] = $arg; + } + + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $pipes = []; + $process = proc_open( + $command, + $descriptorSpec, + $pipes, + $this->cwd($customOptions), + $this->env() + ); + + if (!is_resource($process)) { + throw new RuntimeException('Could not start Claude Code process.'); + } + + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $stdout = ''; + $stderr = ''; + $started = time(); + $exitCode = null; + + while (true) { + $stdout .= stream_get_contents($pipes[1]); + $stderr .= stream_get_contents($pipes[2]); + $status = proc_get_status($process); + + if (!$status['running']) { + break; + } + + if ((time() - $started) > $timeout) { + proc_terminate($process); + $exitCode = 124; + break; + } + + usleep(100000); + } + + $stdout .= stream_get_contents($pipes[1]); + $stderr .= stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $closedExitCode = proc_close($process); + if ($exitCode === null) { + $exitCode = $closedExitCode; + } + + if ($exitCode !== 0) { + throw new RuntimeException( + sprintf('Claude Code exited with status %d: %s', $exitCode, $this->truncate(trim($stderr))) + ); + } + + $text = trim($stdout); + if ($text === '') { + throw new RuntimeException('Claude Code returned an empty response.'); + } + + return [ + 'id' => 'claude-code-' . gmdate('YmdHis') . '-' . substr(sha1($text), 0, 12), + 'text' => $text, + 'exit_code' => $exitCode, + ]; + } + + /** + * Gets the Claude Code binary path/name. + * + * @return string Binary. + */ + private function binary(): string + { + $binary = getenv('AI_PROVIDER_CLAUDE_CODE_BIN') ?: 'claude'; + + if (defined('AI_PROVIDER_CLAUDE_CODE_BIN')) { + $binary = (string) constant('AI_PROVIDER_CLAUDE_CODE_BIN'); + } + + if (function_exists('apply_filters')) { + $binary = apply_filters('ai_provider_claude_code_bin', $binary); + } + + return is_string($binary) && $binary !== '' ? $binary : 'claude'; + } + + /** + * Gets the process working directory. + * + * @param array $customOptions Custom model options. + * @return string|null Working directory. + */ + private function cwd(array $customOptions): ?string + { + if (isset($customOptions['cwd']) && is_string($customOptions['cwd']) && $customOptions['cwd'] !== '') { + return $customOptions['cwd']; + } + + if (defined('ABSPATH')) { + return ABSPATH; + } + + $cwd = getcwd(); + return $cwd !== false ? $cwd : null; + } + + /** + * Gets the process timeout in seconds. + * + * @param array $customOptions Custom model options. + * @return int Timeout. + */ + private function timeout(array $customOptions): int + { + if (isset($customOptions['timeout']) && is_numeric($customOptions['timeout'])) { + return max(1, (int) $customOptions['timeout']); + } + + $timeout = getenv('AI_PROVIDER_CLAUDE_CODE_TIMEOUT'); + if ($timeout !== false && is_numeric($timeout)) { + return max(1, (int) $timeout); + } + + return self::DEFAULT_TIMEOUT; + } + + /** + * Gets additional CLI arguments. + * + * @param array $customOptions Custom model options. + * @return list Extra args. + */ + private function extraArgs(array $customOptions): array + { + if (!isset($customOptions['args']) || !is_array($customOptions['args'])) { + return []; + } + + $args = []; + foreach ($customOptions['args'] as $arg) { + if (is_scalar($arg) && (string) $arg !== '') { + $args[] = (string) $arg; + } + } + + return $args; + } + + /** + * Gets process environment overrides. + * + * @return array|null Environment. + */ + private function env(): ?array + { + $env = null; + if (function_exists('apply_filters')) { + $filtered = apply_filters('ai_provider_claude_code_env', null); + if (is_array($filtered)) { + $env = []; + foreach ($filtered as $key => $value) { + if (is_string($key) && is_scalar($value)) { + $env[$key] = (string) $value; + } + } + } + } + + return $env; + } + + /** + * Truncates process errors before surfacing them. + * + * @param string $value Raw text. + * @return string Truncated text. + */ + private function truncate(string $value): string + { + if (strlen($value) <= 800) { + return $value; + } + + return substr($value, 0, 800) . '...'; + } +} diff --git a/carried-plugins/ai-provider-for-claude-code/src/autoload.php b/carried-plugins/ai-provider-for-claude-code/src/autoload.php new file mode 100644 index 0000000..e0aa4bb --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/src/autoload.php @@ -0,0 +1,20 @@ +/dev/null 2>&1; then + UPDATED_ITEMS+=("carried plugin $slug") + fi + done +} diff --git a/lib/homeboy.sh b/lib/homeboy.sh index 7657373..fdafdf6 100644 --- a/lib/homeboy.sh +++ b/lib/homeboy.sh @@ -364,5 +364,5 @@ print_homeboy_verification_commands() { echo " homeboy extension show wordpress" echo " homeboy project show " echo " homeboy project components list " - echo " ./scripts/verify-homeboy-codebox-canary.sh --workspace --secret-env --agents-api --agent-runtime --agent-runtime-tools --provider-plugin-path # opt-in model-backed Codebox canary; add --run to execute" + echo " ./scripts/verify-homeboy-codebox-canary.sh --workspace --secret-env --agents-api --agent-runtime --agent-runtime-tools --provider-plugin-path # opt-in model-backed Codebox canary; use --provider claude-code and the carried ai-provider-for-claude-code path for Claude Code; add --run to execute" } diff --git a/setup.sh b/setup.sh index 354442a..8292b07 100755 --- a/setup.sh +++ b/setup.sh @@ -23,7 +23,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Source shared modules -for lib in common detect wordpress infrastructure data-machine homeboy skills summary cli-channel runtime-signature agents-md-guidance; do +for lib in common detect wordpress infrastructure data-machine carried-plugins homeboy skills summary cli-channel runtime-signature agents-md-guidance; do source "$SCRIPT_DIR/lib/${lib}.sh" done @@ -327,6 +327,7 @@ if [ "$RUNTIME_ONLY" != true ]; then create_service_user install_data_machine create_dm_agent + sync_carried_plugins install_extra_plugins setup_homeboy_project setup_nginx diff --git a/tests/carried-claude-code-plugin.sh b/tests/carried-claude-code-plugin.sh new file mode 100644 index 0000000..50ae85c --- /dev/null +++ b/tests/carried-claude-code-plugin.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# tests/carried-claude-code-plugin.sh — Claude Code runtime syncs its carried provider plugin. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +SITE_PATH="$TMP/site" +mkdir -p "$SITE_PATH/wp-content/plugins" + +export SCRIPT_DIR +export SITE_PATH +export DRY_RUN=true +export IS_STUDIO=false +export MULTISITE=false +export WP_CMD=wp +export WP_ROOT_FLAG= + +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/common.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/wordpress.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/carried-plugins.sh" + +DETECTED_RUNTIMES=(claude-code) +RUNTIME=claude-code + +output="$(sync_carried_plugins)" + +case "$output" in + *"Syncing carried plugin: ai-provider-for-claude-code"*"wp plugin activate ai-provider-for-claude-code"*) ;; + *) + printf 'Expected Claude Code carried plugin sync, got:\n%s\n' "$output" >&2 + exit 1 + ;; +esac + +DETECTED_RUNTIMES=(opencode) +RUNTIME=opencode + +output="$(sync_carried_plugins)" +if [ -n "$output" ]; then + printf 'Expected no carried plugin sync for opencode, got:\n%s\n' "$output" >&2 + exit 1 +fi + +echo "PASS: tests/carried-claude-code-plugin.sh" diff --git a/upgrade.sh b/upgrade.sh index 281d691..214ffd8 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -7,7 +7,8 @@ # 1. Detect environment (auto-detects local vs VPS, runtime, chat bridge — # supports kimaki, cc-connect, telegram). # 2. Update setup-installed Data Machine plugins to latest tagged releases, -# and sync WP Codebox (subtree-packaged) to its latest tag when installed. +# sync carried provider plugins, and sync WP Codebox (subtree-packaged) +# to its latest tag when installed. # 3. Sync chat-bridge config (dispatches per bridge) # kimaki: # VPS: /opt/kimaki-config (plugins + post-upgrade.sh + skill filters) @@ -66,7 +67,7 @@ TIMESTAMP="$(date +%Y%m%d-%H%M%S)" # Source shared modules (common, detect needed for environment resolution; # wordpress is needed for wp_cmd helper used by compose and plugin updates). -for lib in common detect wordpress data-machine wp-codebox homeboy skills cli-channel runtime-signature agents-md-guidance; do +for lib in common detect wordpress data-machine carried-plugins wp-codebox homeboy skills cli-channel runtime-signature agents-md-guidance; do source "$SCRIPT_DIR/lib/${lib}.sh" done @@ -176,7 +177,8 @@ NEVER TOUCHED: DEFAULT TOUCHES: - data-machine and data-machine-code — updates setup-installed git checkouts to their latest version tags. Non-git plugin directories are - skipped. Use --skip-plugins to skip this phase. + skipped. Carried provider plugins are synced from this repo when their + runtime is present. Use --skip-plugins to skip this phase. - opencode.json — additive repair. Adds managed plugin entries the user is missing (dm-context-filter.ts and dm-agent-sync.ts on Kimaki bridges) and migrates "agent.build.prompt" to top-level "instructions" @@ -324,6 +326,7 @@ _run_filter_active() { update_data_machine_plugins() { _run_filter_active plugins || return 0 upgrade_data_machine_plugins + sync_carried_plugins update_wp_codebox_plugin_subtree } From 91d526ef03ec195d31258a4b05fb6c1ead3d535c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 7 Jun 2026 20:26:06 -0400 Subject: [PATCH 2/6] replace Claude Code CLI provider with OAuth API --- README.md | 12 +- .../ai-provider-for-claude-code/README.md | 27 +- .../ai-provider-for-claude-code.php | 2 +- .../ClaudeCodeModelMetadataDirectory.php | 9 +- .../src/Provider/ClaudeCodeOAuthClient.php | 155 ++++++++++ .../src/Provider/ClaudeCodeProvider.php | 41 ++- .../ClaudeCodeProviderAvailability.php | 15 +- .../ClaudeCodeRequestAuthentication.php | 69 +++++ .../ClaudeCodeTextGenerationModel.php | 264 +++++++++++------- .../src/Provider/ClaudeCodeTokenStore.php | 141 ++++++++++ .../src/Runtime/ClaudeCodeProcess.php | 257 ----------------- lib/agents-md-guidance.sh | 2 +- tests/carried-claude-code-plugin.sh | 17 ++ 13 files changed, 612 insertions(+), 399 deletions(-) create mode 100644 carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeOAuthClient.php create mode 100644 carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeRequestAuthentication.php create mode 100644 carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTokenStore.php delete mode 100644 carried-plugins/ai-provider-for-claude-code/src/Runtime/ClaudeCodeProcess.php diff --git a/README.md b/README.md index be9b6ca..f6f5bbc 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ SITE_DOMAIN=example.com ./setup.sh --dry-run | **[OpenCode](https://opencode.ai)**, **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)**, or **[Studio Code](https://developer.wordpress.com/studio/)** | AI coding agent runtime | Selected via `--runtime` | | **[Data Machine](https://github.com/Extra-Chill/data-machine)** | Memory (SOUL/USER/MEMORY.md), self-scheduling, AI tools, Agent Ping | No — wp-coding-agents composes on top of DM | | **[Data Machine Code](https://github.com/Extra-Chill/data-machine-code)** | Workspace management, GitHub integration, git operations | Installed with Data Machine | -| **AI Provider for Claude Code** | wp-coding-agents-carried WP AI Client provider backed by the local Claude Code CLI session | Installed when Claude Code is selected or detected | +| **AI Provider for Claude Code** | wp-coding-agents-carried WP AI Client provider backed by Claude Code OAuth credentials | Installed when Claude Code is selected or detected | | **[Homeboy](https://github.com/Extra-Chill/homeboy)** | Optional developer power layer for project status, component-aware checks, review loops, and WordPress extension verification | `--with-homeboy` | | **[Kimaki](https://kimaki.xyz)**, **[cc-connect](https://github.com/nichochar/cc-connect)**, or **[opencode-telegram](https://github.com/grinev/opencode-telegram-bot)** | Chat bridge (Discord, multi-platform, or Telegram) | `--no-chat` | | **SessionStart hook** | Syncs Data Machine agents into CLAUDE.md on every session (Claude Code and Studio Code) | Always installed | @@ -256,16 +256,16 @@ When the selected or detected runtime includes Claude Code, wp-coding-agents syn wp-content/plugins/ai-provider-for-claude-code ``` -This plugin registers WP AI Client provider id `claude-code`. It is backed by the locally authenticated `claude` CLI available to the host or Codebox sandbox. It is not an official Anthropic API provider, does not use an Anthropic API key, and does not require WP AI Gateway for Homeboy Codebox cooking. +This plugin registers WP AI Client provider id `claude-code`. It is backed by Claude Code OAuth credentials and sends Anthropic Messages API requests with Claude Code subscription headers. It is not an official Anthropic API-key provider and does not require WP AI Gateway for Homeboy Codebox cooking. Use WP AI Gateway only when an external client needs an OpenAI-compatible WordPress endpoint. Homeboy Codebox tasks can select the provider directly with `--provider claude-code` and pass the carried provider plugin path through the provider config. Configuration: -- `AI_PROVIDER_CLAUDE_CODE_BIN` overrides the Claude Code binary path/name. -- `AI_PROVIDER_CLAUDE_CODE_TIMEOUT` sets the default process timeout in seconds. -- Model custom option `cwd` sets the process working directory. -- Model custom option `args` appends additional Claude Code CLI arguments. +- `AI_PROVIDER_CLAUDE_CODE_REFRESH_TOKEN` provides the Claude Code OAuth refresh token. +- `AI_PROVIDER_CLAUDE_CODE_ACCESS_TOKEN` and `AI_PROVIDER_CLAUDE_CODE_EXPIRES_AT` optionally provide a cached access token. +- The same names may be supplied as WordPress constants. +- `ai_provider_claude_code_oauth_tokens` can filter token data at runtime. `--with-homeboy` adds Homeboy as an optional, recommended developer power layer. Homeboy is not bundled or vendorized by wp-coding-agents; setup verifies or installs the external Homeboy CLI, verifies the WordPress extension, then exposes its availability to Data Machine Code so composed agent instructions can include Homeboy workflows. diff --git a/carried-plugins/ai-provider-for-claude-code/README.md b/carried-plugins/ai-provider-for-claude-code/README.md index 3b825bc..0572231 100644 --- a/carried-plugins/ai-provider-for-claude-code/README.md +++ b/carried-plugins/ai-provider-for-claude-code/README.md @@ -1,27 +1,20 @@ # AI Provider for Claude Code -`ai-provider-for-claude-code` is a wp-coding-agents-carried WP AI Client provider backed by the locally authenticated Claude Code CLI. +`ai-provider-for-claude-code` is a wp-coding-agents-carried WP AI Client provider backed by Claude Code OAuth credentials. -It is not an official Anthropic API provider. It does not use an Anthropic API key and does not implement the Anthropic Messages API. Requests are executed by the `claude` binary available to the WordPress host or Codebox sandbox. +It is not an official Anthropic API-key provider. It sends Anthropic Messages API requests with Claude Code OAuth headers and does not execute the `claude` binary. ## Provider - Provider ID: `claude-code` -- Models: `claude-code`, `sonnet`, `opus`, `haiku` -- Auth: existing Claude Code CLI session on the host/sandbox - -`claude-code` uses the CLI default model. The named models pass `--model ` to Claude Code. +- Models: `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-haiku-4-5` +- Auth: Claude Code OAuth refresh/access token credentials ## Configuration -- `AI_PROVIDER_CLAUDE_CODE_BIN`: override the Claude Code binary path/name. Defaults to `claude`. -- `AI_PROVIDER_CLAUDE_CODE_TIMEOUT`: process timeout in seconds. Defaults to `600`. -- `AI_PROVIDER_CLAUDE_CODE_BIN` constant: WordPress constant alternative for the binary path/name. -- `ai_provider_claude_code_bin` filter: override the binary path/name. -- `ai_provider_claude_code_env` filter: return environment variables for the Claude Code process. - -Model custom options: - -- `cwd`: process working directory. -- `timeout`: per-request process timeout in seconds. -- `args`: additional CLI args appended to the `claude -p` command. +- `AI_PROVIDER_CLAUDE_CODE_REFRESH_TOKEN`: Claude Code OAuth refresh token. +- `AI_PROVIDER_CLAUDE_CODE_ACCESS_TOKEN`: optional cached access token. +- `AI_PROVIDER_CLAUDE_CODE_EXPIRES_AT`: optional Unix timestamp for the cached access token expiry. +- `AI_PROVIDER_CLAUDE_CODE_USER_AGENT`: optional Claude CLI user agent override. +- WordPress constants with the same names are also supported. +- `ai_provider_claude_code_oauth_tokens` filter: override token data at runtime. diff --git a/carried-plugins/ai-provider-for-claude-code/ai-provider-for-claude-code.php b/carried-plugins/ai-provider-for-claude-code/ai-provider-for-claude-code.php index ee24ef9..ad4b043 100644 --- a/carried-plugins/ai-provider-for-claude-code/ai-provider-for-claude-code.php +++ b/carried-plugins/ai-provider-for-claude-code/ai-provider-for-claude-code.php @@ -3,7 +3,7 @@ /** * Plugin Name: AI Provider for Claude Code * Plugin URI: https://github.com/Extra-Chill/wp-coding-agents - * Description: WP AI Client provider backed by the locally authenticated Claude Code CLI. + * Description: WP AI Client provider backed by Claude Code OAuth credentials. * Requires at least: 6.9 * Requires PHP: 7.4 * Version: 0.1.0 diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeModelMetadataDirectory.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeModelMetadataDirectory.php index 9d9e785..dface60 100644 --- a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeModelMetadataDirectory.php +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeModelMetadataDirectory.php @@ -13,7 +13,7 @@ use WordPress\AiClient\Providers\Models\Enums\OptionEnum; /** - * Model metadata directory for Claude Code CLI models. + * Model metadata directory for Claude Code models. */ class ClaudeCodeModelMetadataDirectory implements ModelMetadataDirectoryInterface { @@ -55,13 +55,18 @@ private function getModelMap(): array { $options = [ new SupportedOption(OptionEnum::systemInstruction()), + new SupportedOption(OptionEnum::maxTokens()), + new SupportedOption(OptionEnum::temperature()), + new SupportedOption(OptionEnum::topP()), + new SupportedOption(OptionEnum::outputMimeType(), ['text/plain', 'application/json']), + new SupportedOption(OptionEnum::outputSchema()), new SupportedOption(OptionEnum::customOptions()), new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]), new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::text()]]), ]; $models = []; - foreach (['claude-code', 'sonnet', 'opus', 'haiku'] as $modelId) { + foreach (['claude-opus-4-7', 'claude-sonnet-4-6', 'claude-haiku-4-5'] as $modelId) { $models[$modelId] = new ModelMetadata( $modelId, $modelId, diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeOAuthClient.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeOAuthClient.php new file mode 100644 index 0000000..152fb84 --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeOAuthClient.php @@ -0,0 +1,155 @@ +tokenStore = $tokenStore; + } + + public function getAccessToken(): string + { + $accessToken = $this->tokenStore->getAccessToken(); + if ($accessToken !== null) { + return $accessToken; + } + + $tokens = $this->tokenStore->getTokens(); + $refreshToken = $tokens['refresh_token'] ?? ''; + if ($refreshToken === '') { + throw new RuntimeException('Claude Code OAuth refresh token is not configured.'); + } + + $data = $this->refreshAccessToken($refreshToken); + if (empty($data['access_token']) || !is_scalar($data['access_token'])) { + throw new RuntimeException('Claude Code OAuth refresh returned an invalid response.'); + } + + $updated = array_merge( + $tokens, + [ + 'access_token' => (string) $data['access_token'], + 'expires_at' => time() + $this->getIntegerValue($data['expires_in'] ?? null, 3600), + ] + ); + + if (!empty($data['refresh_token']) && is_scalar($data['refresh_token'])) { + $updated['refresh_token'] = (string) $data['refresh_token']; + } + + $this->tokenStore->updateTokens($updated); + return (string) $data['access_token']; + } + + /** + * @return array + */ + private function refreshAccessToken(string $refreshToken): array + { + $body = [ + 'grant_type' => 'refresh_token', + 'client_id' => self::CLIENT_ID, + 'refresh_token' => $refreshToken, + ]; + + if (function_exists('wp_remote_post')) { + return $this->refreshAccessTokenWithWordPress($body); + } + + $context = stream_context_create( + [ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => json_encode($body), + 'ignore_errors' => true, + 'timeout' => 20, + ], + ] + ); + $responseBody = file_get_contents(self::TOKEN_URL, false, $context); + if ($responseBody === false) { + throw new RuntimeException('Claude Code OAuth refresh failed.'); + } + + $data = json_decode($responseBody, true); + if (!is_array($data)) { + throw new RuntimeException('Claude Code OAuth refresh returned an invalid response.'); + } + + /** @var array $data */ + return $data; + } + + /** + * @param array $body Request body. + * @return array + */ + private function refreshAccessTokenWithWordPress(array $body): array + { + $wpRemotePost = 'wp_remote_post'; + // @phpstan-ignore-next-line WordPress HTTP API is available at runtime when function_exists() passes. + $response = $wpRemotePost(self::TOKEN_URL, [ + 'body' => wp_json_encode($body), + 'headers' => ['Content-Type' => 'application/json'], + 'timeout' => 20, + ]); + + $isWpError = 'is_wp_error'; + // @phpstan-ignore-next-line WordPress error helper is available at runtime when function_exists() passes. + if (function_exists('is_wp_error') && $isWpError($response)) { + throw new RuntimeException('Claude Code OAuth refresh failed.'); + } + + $wpRemoteRetrieveResponseCode = 'wp_remote_retrieve_response_code'; + $rawStatusCode = 0; + if (function_exists('wp_remote_retrieve_response_code')) { + // @phpstan-ignore-next-line WordPress HTTP helper is available at runtime when function_exists() passes. + $rawStatusCode = $wpRemoteRetrieveResponseCode($response); + } + $statusCode = is_numeric($rawStatusCode) ? (int) $rawStatusCode : 0; + if ($statusCode < 200 || $statusCode >= 300) { + throw new RuntimeException('Claude Code OAuth refresh failed.'); + } + + $wpRemoteRetrieveBody = 'wp_remote_retrieve_body'; + $rawResponseBody = ''; + if (function_exists('wp_remote_retrieve_body')) { + // @phpstan-ignore-next-line WordPress HTTP helper is available at runtime when function_exists() passes. + $rawResponseBody = $wpRemoteRetrieveBody($response); + } + $responseBody = is_scalar($rawResponseBody) ? (string) $rawResponseBody : ''; + $data = json_decode($responseBody, true); + if (!is_array($data)) { + throw new RuntimeException('Claude Code OAuth refresh returned an invalid response.'); + } + + /** @var array $data */ + return $data; + } + + /** + * @param mixed $value Raw value. + */ + private function getIntegerValue($value, int $fallback): int + { + return is_numeric($value) ? (int) $value : $fallback; + } +} diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProvider.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProvider.php index 1417a4e..b93db4e 100644 --- a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProvider.php +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProvider.php @@ -4,21 +4,31 @@ namespace ExtraChill\ClaudeCodeAiProvider\Provider; -use ExtraChill\ClaudeCodeAiProvider\Runtime\ClaudeCodeProcess; use WordPress\AiClient\Common\Exception\RuntimeException; -use WordPress\AiClient\Providers\AbstractProvider; +use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiProvider; use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; +use WordPress\AiClient\Providers\Contracts\ProviderWithRequestAuthenticationInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; +use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface; +use WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; /** - * Provider backed by the local Claude Code CLI session. + * Provider for Claude Code subscription-backed access. */ -class ClaudeCodeProvider extends AbstractProvider +class ClaudeCodeProvider extends AbstractApiProvider implements ProviderWithRequestAuthenticationInterface { + /** + * {@inheritDoc} + */ + protected static function baseUrl(): string + { + return 'https://api.anthropic.com/v1'; + } + /** * {@inheritDoc} */ @@ -28,11 +38,7 @@ protected static function createModel( ): ModelInterface { foreach ($modelMetadata->getSupportedCapabilities() as $capability) { if ($capability->isTextGeneration()) { - return new ClaudeCodeTextGenerationModel( - $modelMetadata, - $providerMetadata, - new ClaudeCodeProcess() - ); + return new ClaudeCodeTextGenerationModel($modelMetadata, $providerMetadata); } } @@ -47,10 +53,10 @@ protected static function createProviderMetadata(): ProviderMetadata return new ProviderMetadata( 'claude-code', 'Claude Code', - ProviderTypeEnum::server(), + ProviderTypeEnum::cloud(), 'https://docs.anthropic.com/en/docs/claude-code', - null, - 'Local Claude Code CLI access using the host or sandbox authenticated session.' + RequestAuthenticationMethod::apiKey(), + 'Claude Code subscription-backed access using Claude OAuth credentials.' ); } @@ -59,7 +65,16 @@ protected static function createProviderMetadata(): ProviderMetadata */ protected static function createProviderAvailability(): ProviderAvailabilityInterface { - return new ClaudeCodeProviderAvailability(new ClaudeCodeProcess()); + return new ClaudeCodeProviderAvailability(new ClaudeCodeTokenStore()); + } + + /** + * {@inheritDoc} + */ + public static function requestAuthentication(): ?RequestAuthenticationInterface + { + $tokenStore = new ClaudeCodeTokenStore(); + return new ClaudeCodeRequestAuthentication($tokenStore, new ClaudeCodeOAuthClient($tokenStore)); } /** diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProviderAvailability.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProviderAvailability.php index 1980576..6145c81 100644 --- a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProviderAvailability.php +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProviderAvailability.php @@ -4,27 +4,26 @@ namespace ExtraChill\ClaudeCodeAiProvider\Provider; -use ExtraChill\ClaudeCodeAiProvider\Runtime\ClaudeCodeProcess; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; /** - * Availability checker for the local Claude Code provider. + * Availability checker for the Claude Code provider. */ class ClaudeCodeProviderAvailability implements ProviderAvailabilityInterface { /** - * @var ClaudeCodeProcess Claude Code process runner. + * @var ClaudeCodeTokenStore Token store. */ - private ClaudeCodeProcess $process; + private ClaudeCodeTokenStore $tokenStore; /** * Constructor. * - * @param ClaudeCodeProcess $process Claude Code process runner. + * @param ClaudeCodeTokenStore $tokenStore Token store. */ - public function __construct(ClaudeCodeProcess $process) + public function __construct(ClaudeCodeTokenStore $tokenStore) { - $this->process = $process; + $this->tokenStore = $tokenStore; } /** @@ -32,6 +31,6 @@ public function __construct(ClaudeCodeProcess $process) */ public function isConfigured(): bool { - return $this->process->isAvailable(); + return $this->tokenStore->hasRefreshToken(); } } diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeRequestAuthentication.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeRequestAuthentication.php new file mode 100644 index 0000000..ff60ef0 --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeRequestAuthentication.php @@ -0,0 +1,69 @@ +tokenStore = $tokenStore; + $this->oauthClient = $oauthClient; + } + + /** + * {@inheritDoc} + */ + public function authenticateRequest(Request $request): Request + { + return $request + ->withHeader('Accept', 'application/json') + ->withHeader('Anthropic-Version', '2023-06-01') + ->withHeader('Anthropic-Beta', self::ANTHROPIC_BETA) + ->withHeader('Anthropic-Dangerous-Direct-Browser-Access', 'true') + ->withHeader('Authorization', 'Bearer ' . $this->oauthClient->getAccessToken()) + ->withHeader('User-Agent', $this->userAgent()) + ->withHeader('X-App', 'cli'); + } + + /** + * {@inheritDoc} + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [], + ]; + } + + private function userAgent(): string + { + $userAgent = getenv('AI_PROVIDER_CLAUDE_CODE_USER_AGENT') ?: ''; + if (defined('AI_PROVIDER_CLAUDE_CODE_USER_AGENT')) { + $userAgent = (string) constant('AI_PROVIDER_CLAUDE_CODE_USER_AGENT'); + } + + return $userAgent !== '' ? $userAgent : 'claude-cli/' . self::CLAUDE_CODE_VERSION; + } +} diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php index d15deea..10c284f 100644 --- a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php @@ -4,15 +4,18 @@ namespace ExtraChill\ClaudeCodeAiProvider\Provider; -use ExtraChill\ClaudeCodeAiProvider\Runtime\ClaudeCodeProcess; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\ModelMessage; -use WordPress\AiClient\Providers\DTO\ProviderMetadata; -use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; -use WordPress\AiClient\Providers\Models\DTO\ModelConfig; -use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; +use WordPress\AiClient\Messages\Enums\MessageRoleEnum; +use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModel; +use WordPress\AiClient\Providers\Http\DTO\Request; +use WordPress\AiClient\Providers\Http\DTO\RequestOptions; +use WordPress\AiClient\Providers\Http\DTO\Response; +use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; +use WordPress\AiClient\Providers\Http\Exception\ResponseException; +use WordPress\AiClient\Providers\Http\Util\ResponseUtil; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; @@ -20,134 +23,207 @@ use WordPress\AiClient\Results\Enums\FinishReasonEnum; /** - * Text generation model backed by `claude -p`. + * Text generation model for Claude Code using Anthropic Messages API. */ -class ClaudeCodeTextGenerationModel implements ModelInterface, TextGenerationModelInterface +class ClaudeCodeTextGenerationModel extends AbstractApiBasedModel implements TextGenerationModelInterface { - /** - * @var ModelMetadata Model metadata. - */ - private ModelMetadata $metadata; - - /** - * @var ProviderMetadata Provider metadata. - */ - private ProviderMetadata $providerMetadata; + private const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."; + private const REQUEST_TIMEOUT_FLOOR = 300.0; + private const CONNECT_TIMEOUT_FLOOR = 120.0; /** - * @var ModelConfig Model config. + * {@inheritDoc} */ - private ModelConfig $config; + public function generateTextResult(array $prompt): GenerativeAiResult + { + $request = new Request( + HttpMethodEnum::POST(), + ClaudeCodeProvider::url('messages'), + ['Content-Type' => 'application/json'], + $this->prepareGenerateTextParams($prompt), + $this->getClaudeCodeRequestOptions() + ); - /** - * @var ClaudeCodeProcess Claude Code process runner. - */ - private ClaudeCodeProcess $process; + $request = $this->getRequestAuthentication()->authenticateRequest($request); + $response = $this->getHttpTransporter()->send($request); + ResponseUtil::throwIfNotSuccessful($response); - /** - * Constructor. - * - * @param ModelMetadata $metadata Model metadata. - * @param ProviderMetadata $providerMetadata Provider metadata. - * @param ClaudeCodeProcess $process Claude Code process runner. - */ - public function __construct( - ModelMetadata $metadata, - ProviderMetadata $providerMetadata, - ClaudeCodeProcess $process - ) { - $this->metadata = $metadata; - $this->providerMetadata = $providerMetadata; - $this->process = $process; - $this->config = ModelConfig::fromArray([]); + return $this->parseResponseToResult($response); } - /** - * {@inheritDoc} - */ - public function metadata(): ModelMetadata + private function getClaudeCodeRequestOptions(): RequestOptions { - return $this->metadata; + $current = $this->getRequestOptions(); + $options = new RequestOptions(); + + $timeout = $current?->getTimeout(); + $timeout = max($timeout ?? 0.0, self::REQUEST_TIMEOUT_FLOOR); + $options->setTimeout($timeout); + + $connectTimeout = $current?->getConnectTimeout(); + $connectTimeoutFloor = min(self::CONNECT_TIMEOUT_FLOOR, $timeout); + $options->setConnectTimeout(max($connectTimeout ?? 0.0, $connectTimeoutFloor)); + + $maxRedirects = $current?->getMaxRedirects(); + if ($maxRedirects !== null) { + $options->setMaxRedirects($maxRedirects); + } + + return $options; } /** - * {@inheritDoc} + * Prepares the Anthropic Messages request payload. + * + * @param list $prompt Prompt messages. + * @return array Request payload. */ - public function providerMetadata(): ProviderMetadata + private function prepareGenerateTextParams(array $prompt): array { - return $this->providerMetadata; + $config = $this->getConfig(); + $system = [ + ['type' => 'text', 'text' => self::CLAUDE_CODE_IDENTITY], + ]; + + if ($config->getSystemInstruction()) { + $system[] = ['type' => 'text', 'text' => $config->getSystemInstruction()]; + } + + $params = [ + 'model' => $this->metadata()->getId(), + 'max_tokens' => $config->getMaxTokens() ?? 4096, + 'messages' => $this->prepareMessages($prompt), + 'system' => $system, + ]; + + $temperature = $config->getTemperature(); + if ($temperature !== null) { + $params['temperature'] = $temperature; + } + + $topP = $config->getTopP(); + if ($topP !== null) { + $params['top_p'] = $topP; + } + + if ($config->getOutputMimeType() === 'application/json' && $config->getOutputSchema()) { + $params['tools'] = [ + [ + 'name' => 'response_schema', + 'description' => 'Return a response matching the requested JSON schema.', + 'input_schema' => $config->getOutputSchema(), + ], + ]; + $params['tool_choice'] = ['type' => 'tool', 'name' => 'response_schema']; + } + + foreach ($config->getCustomOptions() as $key => $value) { + if (isset($params[$key])) { + throw new InvalidArgumentException( + sprintf('The custom option "%s" conflicts with an existing Claude Code request parameter.', $key) + ); + } + $params[$key] = $value; + } + + return $params; } /** - * {@inheritDoc} + * Converts prompt messages to Anthropic Messages format. + * + * @param list $messages Prompt messages. + * @return list> Messages. */ - public function setConfig(ModelConfig $config): void + private function prepareMessages(array $messages): array { - $this->config = $config; + $output = []; + foreach ($messages as $message) { + $content = []; + foreach ($message->getParts() as $part) { + if (!$part->getType()->isText()) { + throw new InvalidArgumentException( + 'Claude Code text generation currently supports text message parts only.' + ); + } + $content[] = ['type' => 'text', 'text' => $part->getText()]; + } + + $output[] = [ + 'role' => $this->roleToAnthropicRole($message->getRole()), + 'content' => $content, + ]; + } + + return $output; } - /** - * {@inheritDoc} - */ - public function getConfig(): ModelConfig + private function roleToAnthropicRole(MessageRoleEnum $role): string { - return $this->config; + return $role->isModel() ? 'assistant' : 'user'; } - /** - * {@inheritDoc} - */ - public function generateTextResult(array $prompt): GenerativeAiResult + private function parseResponseToResult(Response $response): GenerativeAiResult { - $customOptions = $this->config->getCustomOptions(); - $result = $this->process->run( - $this->preparePrompt($prompt), - $this->metadata->getId(), - $customOptions - ); + $data = $response->getData(); + if (!is_array($data)) { + $data = json_decode((string) $response->getBody(), true); + } + if (!is_array($data)) { + throw ResponseException::fromMissingData('Claude Code', 'content'); + } + + $text = $this->extractText($data); + if ($text === '') { + throw ResponseException::fromMissingData('Claude Code', 'content.text'); + } + + $usage = isset($data['usage']) && is_array($data['usage']) ? $data['usage'] : []; + $inputTokens = $this->getIntegerValue($usage['input_tokens'] ?? null); + $outputTokens = $this->getIntegerValue($usage['output_tokens'] ?? null); return new GenerativeAiResult( - $result['id'], - [new Candidate(new ModelMessage([new MessagePart($result['text'])]), FinishReasonEnum::stop())], - new TokenUsage(0, 0, 0), - $this->providerMetadata, - $this->metadata, - [ - 'command' => 'claude-code', - 'exit_code' => $result['exit_code'], - ] + isset($data['id']) && is_string($data['id']) ? $data['id'] : '', + [new Candidate(new ModelMessage([new MessagePart($text)]), FinishReasonEnum::stop())], + new TokenUsage($inputTokens, $outputTokens, $inputTokens + $outputTokens), + $this->providerMetadata(), + $this->metadata(), + $data ); } /** - * Converts WP AI Client messages to a Claude Code prompt string. - * - * @param list $messages Prompt messages. - * @return string Prompt string. + * @param array $data Response data. */ - private function preparePrompt(array $messages): string + private function extractText(array $data): string { - $chunks = []; - $systemInstruction = $this->config->getSystemInstruction(); - if ($systemInstruction) { - $chunks[] = "System:\n" . $systemInstruction; + $parts = []; + if (!isset($data['content']) || !is_array($data['content'])) { + return ''; } - foreach ($messages as $message) { - $parts = []; - foreach ($message->getParts() as $part) { - if (!$part->getType()->isText()) { - throw new InvalidArgumentException( - 'Claude Code text generation currently supports text message parts only.' - ); + foreach ($data['content'] as $part) { + if (!is_array($part)) { + continue; + } + if (($part['type'] ?? '') === 'text' && isset($part['text']) && is_string($part['text'])) { + $parts[] = $part['text']; + } elseif (($part['type'] ?? '') === 'tool_use' && isset($part['input'])) { + $encoded = function_exists('wp_json_encode') ? wp_json_encode($part['input']) : json_encode($part['input']); + if (is_string($encoded)) { + $parts[] = $encoded; } - $parts[] = $part->getText(); } - - $role = $message->getRole()->isModel() ? 'Assistant' : 'User'; - $chunks[] = $role . ":\n" . implode("\n", $parts); } - return implode("\n\n", $chunks); + return implode("\n", array_filter($parts, 'is_string')); + } + + /** + * @param mixed $value Raw value. + */ + private function getIntegerValue($value): int + { + return is_numeric($value) ? (int) $value : 0; } } diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTokenStore.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTokenStore.php new file mode 100644 index 0000000..8e28ed5 --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTokenStore.php @@ -0,0 +1,141 @@ + $tokenData */ + $tokenData = $tokens; + $tokens = $this->addEnvironmentTokens($tokenData); + $tokens = $this->addConstantTokens($tokens); + + if (function_exists('apply_filters')) { + $tokens = apply_filters('ai_provider_claude_code_oauth_tokens', $tokens); + } + + if (!is_array($tokens)) { + return []; + } + + /** @var array $tokens */ + return $this->sanitizeTokens($tokens); + } + + /** + * @param array $tokens Token data. + */ + public function updateTokens(array $tokens): void + { + if (!function_exists('update_option')) { + return; + } + + update_option(self::OPTION_NAME, $this->sanitizeTokens($tokens), false); + } + + public function hasRefreshToken(): bool + { + $tokens = $this->getTokens(); + return !empty($tokens['refresh_token']); + } + + public function getAccessToken(): ?string + { + $tokens = $this->getTokens(); + $accessToken = $tokens['access_token'] ?? ''; + $expiresAt = $tokens['expires_at'] ?? 0; + + if ($accessToken === '' || $expiresAt <= time() + 60) { + return null; + } + + return $accessToken; + } + + /** + * @param array $tokens Token data. + * @return array Token data. + */ + private function addEnvironmentTokens(array $tokens): array + { + foreach ($this->tokenSourceMap() as $name => $tokenKey) { + $value = getenv($name); + if ($value !== false) { + $tokens[$tokenKey] = $value; + } + } + + return $tokens; + } + + /** + * @param array $tokens Token data. + * @return array Token data. + */ + private function addConstantTokens(array $tokens): array + { + foreach ($this->tokenSourceMap() as $constantName => $tokenKey) { + if (defined($constantName)) { + $tokens[$tokenKey] = constant($constantName); + } + } + + return $tokens; + } + + /** + * @return array + */ + private function tokenSourceMap(): array + { + return [ + 'AI_PROVIDER_CLAUDE_CODE_ACCESS_TOKEN' => 'access_token', + 'AI_PROVIDER_CLAUDE_CODE_REFRESH_TOKEN' => 'refresh_token', + 'AI_PROVIDER_CLAUDE_CODE_EXPIRES_AT' => 'expires_at', + ]; + } + + /** + * @param array $tokens Raw token data. + * @return ClaudeCodeTokens Sanitized token data. + */ + private function sanitizeTokens(array $tokens): array + { + /** @var ClaudeCodeTokens $sanitized */ + $sanitized = []; + foreach (['access_token', 'refresh_token'] as $key) { + if (isset($tokens[$key]) && is_scalar($tokens[$key])) { + $sanitized[$key] = trim((string) $tokens[$key]); + } + } + + if (isset($tokens['expires_at']) && is_numeric($tokens['expires_at'])) { + $sanitized['expires_at'] = (int) $tokens['expires_at']; + } + + return $sanitized; + } +} diff --git a/carried-plugins/ai-provider-for-claude-code/src/Runtime/ClaudeCodeProcess.php b/carried-plugins/ai-provider-for-claude-code/src/Runtime/ClaudeCodeProcess.php deleted file mode 100644 index ff9cb82..0000000 --- a/carried-plugins/ai-provider-for-claude-code/src/Runtime/ClaudeCodeProcess.php +++ /dev/null @@ -1,257 +0,0 @@ -binary(); - if (strpos($binary, '/') !== false) { - return is_executable($binary); - } - - $command = 'command -v ' . escapeshellarg($binary) . ' >/dev/null 2>&1'; - $process = proc_open($command, [], $pipes); - if (!is_resource($process)) { - return false; - } - - return proc_close($process) === 0; - } - - /** - * Runs Claude Code for a prompt. - * - * @param string $prompt Prompt text. - * @param string $modelId Model ID. - * @param array $customOptions Custom model options. - * @return array{id:string,text:string,exit_code:int} - */ - public function run(string $prompt, string $modelId, array $customOptions = []): array - { - $timeout = $this->timeout($customOptions); - $command = [ - $this->binary(), - '-p', - $prompt, - '--output-format', - 'text', - ]; - - if ($modelId !== 'claude-code') { - $command[] = '--model'; - $command[] = $modelId; - } - - foreach ($this->extraArgs($customOptions) as $arg) { - $command[] = $arg; - } - - $descriptorSpec = [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ]; - - $pipes = []; - $process = proc_open( - $command, - $descriptorSpec, - $pipes, - $this->cwd($customOptions), - $this->env() - ); - - if (!is_resource($process)) { - throw new RuntimeException('Could not start Claude Code process.'); - } - - fclose($pipes[0]); - stream_set_blocking($pipes[1], false); - stream_set_blocking($pipes[2], false); - - $stdout = ''; - $stderr = ''; - $started = time(); - $exitCode = null; - - while (true) { - $stdout .= stream_get_contents($pipes[1]); - $stderr .= stream_get_contents($pipes[2]); - $status = proc_get_status($process); - - if (!$status['running']) { - break; - } - - if ((time() - $started) > $timeout) { - proc_terminate($process); - $exitCode = 124; - break; - } - - usleep(100000); - } - - $stdout .= stream_get_contents($pipes[1]); - $stderr .= stream_get_contents($pipes[2]); - fclose($pipes[1]); - fclose($pipes[2]); - $closedExitCode = proc_close($process); - if ($exitCode === null) { - $exitCode = $closedExitCode; - } - - if ($exitCode !== 0) { - throw new RuntimeException( - sprintf('Claude Code exited with status %d: %s', $exitCode, $this->truncate(trim($stderr))) - ); - } - - $text = trim($stdout); - if ($text === '') { - throw new RuntimeException('Claude Code returned an empty response.'); - } - - return [ - 'id' => 'claude-code-' . gmdate('YmdHis') . '-' . substr(sha1($text), 0, 12), - 'text' => $text, - 'exit_code' => $exitCode, - ]; - } - - /** - * Gets the Claude Code binary path/name. - * - * @return string Binary. - */ - private function binary(): string - { - $binary = getenv('AI_PROVIDER_CLAUDE_CODE_BIN') ?: 'claude'; - - if (defined('AI_PROVIDER_CLAUDE_CODE_BIN')) { - $binary = (string) constant('AI_PROVIDER_CLAUDE_CODE_BIN'); - } - - if (function_exists('apply_filters')) { - $binary = apply_filters('ai_provider_claude_code_bin', $binary); - } - - return is_string($binary) && $binary !== '' ? $binary : 'claude'; - } - - /** - * Gets the process working directory. - * - * @param array $customOptions Custom model options. - * @return string|null Working directory. - */ - private function cwd(array $customOptions): ?string - { - if (isset($customOptions['cwd']) && is_string($customOptions['cwd']) && $customOptions['cwd'] !== '') { - return $customOptions['cwd']; - } - - if (defined('ABSPATH')) { - return ABSPATH; - } - - $cwd = getcwd(); - return $cwd !== false ? $cwd : null; - } - - /** - * Gets the process timeout in seconds. - * - * @param array $customOptions Custom model options. - * @return int Timeout. - */ - private function timeout(array $customOptions): int - { - if (isset($customOptions['timeout']) && is_numeric($customOptions['timeout'])) { - return max(1, (int) $customOptions['timeout']); - } - - $timeout = getenv('AI_PROVIDER_CLAUDE_CODE_TIMEOUT'); - if ($timeout !== false && is_numeric($timeout)) { - return max(1, (int) $timeout); - } - - return self::DEFAULT_TIMEOUT; - } - - /** - * Gets additional CLI arguments. - * - * @param array $customOptions Custom model options. - * @return list Extra args. - */ - private function extraArgs(array $customOptions): array - { - if (!isset($customOptions['args']) || !is_array($customOptions['args'])) { - return []; - } - - $args = []; - foreach ($customOptions['args'] as $arg) { - if (is_scalar($arg) && (string) $arg !== '') { - $args[] = (string) $arg; - } - } - - return $args; - } - - /** - * Gets process environment overrides. - * - * @return array|null Environment. - */ - private function env(): ?array - { - $env = null; - if (function_exists('apply_filters')) { - $filtered = apply_filters('ai_provider_claude_code_env', null); - if (is_array($filtered)) { - $env = []; - foreach ($filtered as $key => $value) { - if (is_string($key) && is_scalar($value)) { - $env[$key] = (string) $value; - } - } - } - } - - return $env; - } - - /** - * Truncates process errors before surfacing them. - * - * @param string $value Raw text. - * @return string Truncated text. - */ - private function truncate(string $value): string - { - if (strlen($value) <= 800) { - return $value; - } - - return substr($value, 0, 800) . '...'; - } -} diff --git a/lib/agents-md-guidance.sh b/lib/agents-md-guidance.sh index 4b2208d..42dc592 100644 --- a/lib/agents-md-guidance.sh +++ b/lib/agents-md-guidance.sh @@ -203,7 +203,7 @@ agents_md_guidance_homeboy_codebox_content() { **Codex provider:** Codebox Codex tasks need the OpenAI Codex provider plugin/runtime overlay and `AI_PROVIDER_OPENAI_CODEX_*` secrets passed via `secret_env`. Never print token values in logs or task instructions. -**Claude Code provider:** Codebox Claude Code tasks use the `ai-provider-for-claude-code` plugin carried by wp-coding-agents. It is backed by the locally authenticated `claude` CLI session in the host or sandbox, not an Anthropic API key and not WP AI Gateway. Provider id: `claude-code`. Use `AI_PROVIDER_CLAUDE_CODE_BIN` only to point at a non-standard binary path; do not pass Claude subscription/session material through task prompts or logs. +**Claude Code provider:** Codebox Claude Code tasks use the `ai-provider-for-claude-code` plugin carried by wp-coding-agents. It is backed by Claude Code OAuth credentials through WP AI Client / PHP AI Client, not the `claude` binary, not an Anthropic API key, and not WP AI Gateway. Provider id: `claude-code`. Provide credentials through `AI_PROVIDER_CLAUDE_CODE_REFRESH_TOKEN` and optional `AI_PROVIDER_CLAUDE_CODE_ACCESS_TOKEN` / `AI_PROVIDER_CLAUDE_CODE_EXPIRES_AT`; never pass Claude subscription/session material through task prompts or logs. **Operator verbs:** `homeboy release`, `homeboy deploy`, `homeboy fleet`, and `homeboy ssh` are operator actions. Use them only when the user explicitly asks for that action. diff --git a/tests/carried-claude-code-plugin.sh b/tests/carried-claude-code-plugin.sh index 50ae85c..abbee80 100644 --- a/tests/carried-claude-code-plugin.sh +++ b/tests/carried-claude-code-plugin.sh @@ -7,8 +7,25 @@ TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT SITE_PATH="$TMP/site" +PROVIDER_DIR="$SCRIPT_DIR/carried-plugins/ai-provider-for-claude-code" mkdir -p "$SITE_PATH/wp-content/plugins" +for required_file in \ + "$PROVIDER_DIR/src/Provider/ClaudeCodeProvider.php" \ + "$PROVIDER_DIR/src/Provider/ClaudeCodeOAuthClient.php" \ + "$PROVIDER_DIR/src/Provider/ClaudeCodeRequestAuthentication.php" \ + "$PROVIDER_DIR/src/Provider/ClaudeCodeTokenStore.php"; do + if [ ! -f "$required_file" ]; then + printf 'Expected Claude Code provider file to exist: %s\n' "$required_file" >&2 + exit 1 + fi +done + +if [ -e "$PROVIDER_DIR/src/Runtime/ClaudeCodeProcess.php" ]; then + printf 'Claude Code provider must use OAuth/API auth, not the local CLI process runtime.\n' >&2 + exit 1 +fi + export SCRIPT_DIR export SITE_PATH export DRY_RUN=true From 21e6d899095875b4918c74706afaa3ac30bd37b6 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 7 Jun 2026 21:16:54 -0400 Subject: [PATCH 3/6] fix Claude Code provider Codebox canary --- .../ai-provider-for-claude-code.php | 9 +++++ .../src/Provider/ClaudeCodeProvider.php | 13 +------ .../ClaudeCodeRequestAuthentication.php | 10 ++--- .../ClaudeCodeTextGenerationModel.php | 39 ++++++++++++++----- scripts/verify-homeboy-codebox-canary.sh | 8 ---- tests/homeboy-codebox-canary.sh | 20 ++++++++++ 6 files changed, 63 insertions(+), 36 deletions(-) diff --git a/carried-plugins/ai-provider-for-claude-code/ai-provider-for-claude-code.php b/carried-plugins/ai-provider-for-claude-code/ai-provider-for-claude-code.php index ad4b043..a24875e 100644 --- a/carried-plugins/ai-provider-for-claude-code/ai-provider-for-claude-code.php +++ b/carried-plugins/ai-provider-for-claude-code/ai-provider-for-claude-code.php @@ -20,6 +20,9 @@ namespace ExtraChill\ClaudeCodeAiProvider; use ExtraChill\ClaudeCodeAiProvider\Provider\ClaudeCodeProvider; +use ExtraChill\ClaudeCodeAiProvider\Provider\ClaudeCodeOAuthClient; +use ExtraChill\ClaudeCodeAiProvider\Provider\ClaudeCodeRequestAuthentication; +use ExtraChill\ClaudeCodeAiProvider\Provider\ClaudeCodeTokenStore; use WordPress\AiClient\AiClient; if (!defined('ABSPATH')) { @@ -44,6 +47,12 @@ function register_provider(): void if (!$registry->hasProvider(ClaudeCodeProvider::class)) { $registry->registerProvider(ClaudeCodeProvider::class); } + + $tokenStore = new ClaudeCodeTokenStore(); + $registry->setProviderRequestAuthentication( + ClaudeCodeProvider::class, + new ClaudeCodeRequestAuthentication($tokenStore, new ClaudeCodeOAuthClient($tokenStore)) + ); } add_action('init', __NAMESPACE__ . '\\register_provider', 5); diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProvider.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProvider.php index b93db4e..15a68f4 100644 --- a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProvider.php +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProvider.php @@ -8,10 +8,8 @@ use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiProvider; use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; -use WordPress\AiClient\Providers\Contracts\ProviderWithRequestAuthenticationInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; -use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface; use WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -19,7 +17,7 @@ /** * Provider for Claude Code subscription-backed access. */ -class ClaudeCodeProvider extends AbstractApiProvider implements ProviderWithRequestAuthenticationInterface +class ClaudeCodeProvider extends AbstractApiProvider { /** * {@inheritDoc} @@ -68,15 +66,6 @@ protected static function createProviderAvailability(): ProviderAvailabilityInte return new ClaudeCodeProviderAvailability(new ClaudeCodeTokenStore()); } - /** - * {@inheritDoc} - */ - public static function requestAuthentication(): ?RequestAuthenticationInterface - { - $tokenStore = new ClaudeCodeTokenStore(); - return new ClaudeCodeRequestAuthentication($tokenStore, new ClaudeCodeOAuthClient($tokenStore)); - } - /** * {@inheritDoc} */ diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeRequestAuthentication.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeRequestAuthentication.php index ff60ef0..29a1af7 100644 --- a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeRequestAuthentication.php +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeRequestAuthentication.php @@ -4,13 +4,13 @@ namespace ExtraChill\ClaudeCodeAiProvider\Provider; -use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface; use WordPress\AiClient\Providers\Http\DTO\Request; +use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; /** * Authenticates requests as Claude Code OAuth traffic. */ -class ClaudeCodeRequestAuthentication implements RequestAuthenticationInterface +class ClaudeCodeRequestAuthentication extends ApiKeyRequestAuthentication { private const CLAUDE_CODE_VERSION = '2.1.75'; private const ANTHROPIC_BETA = 'claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14,interleaved-thinking-2025-05-14'; @@ -27,6 +27,7 @@ class ClaudeCodeRequestAuthentication implements RequestAuthenticationInterface public function __construct(ClaudeCodeTokenStore $tokenStore, ClaudeCodeOAuthClient $oauthClient) { + parent::__construct('claude-code-oauth'); $this->tokenStore = $tokenStore; $this->oauthClient = $oauthClient; } @@ -51,10 +52,7 @@ public function authenticateRequest(Request $request): Request */ public static function getJsonSchema(): array { - return [ - 'type' => 'object', - 'properties' => [], - ]; + return parent::getJsonSchema(); } private function userAgent(): string diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php index 10c284f..bccce1d 100644 --- a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php @@ -141,12 +141,7 @@ private function prepareMessages(array $messages): array foreach ($messages as $message) { $content = []; foreach ($message->getParts() as $part) { - if (!$part->getType()->isText()) { - throw new InvalidArgumentException( - 'Claude Code text generation currently supports text message parts only.' - ); - } - $content[] = ['type' => 'text', 'text' => $part->getText()]; + $content[] = ['type' => 'text', 'text' => $this->prepareMessagePartText($part)]; } $output[] = [ @@ -158,6 +153,24 @@ private function prepareMessages(array $messages): array return $output; } + private function prepareMessagePartText(MessagePart $part): string + { + if ($part->getType()->isText()) { + return (string) $part->getText(); + } + + $payload = ['type' => $part->getType()->value]; + if ($part->getType()->isFunctionCall() && $part->getFunctionCall()) { + $payload['function_call'] = $part->getFunctionCall()->toArray(); + } elseif ($part->getType()->isFunctionResponse() && $part->getFunctionResponse()) { + $payload['function_response'] = $part->getFunctionResponse()->toArray(); + } elseif ($part->getType()->isFile() && $part->getFile()) { + $payload['file'] = $part->getFile()->toArray(); + } + + return $this->encodeJson($payload); + } + private function roleToAnthropicRole(MessageRoleEnum $role): string { return $role->isModel() ? 'assistant' : 'user'; @@ -209,16 +222,22 @@ private function extractText(array $data): string if (($part['type'] ?? '') === 'text' && isset($part['text']) && is_string($part['text'])) { $parts[] = $part['text']; } elseif (($part['type'] ?? '') === 'tool_use' && isset($part['input'])) { - $encoded = function_exists('wp_json_encode') ? wp_json_encode($part['input']) : json_encode($part['input']); - if (is_string($encoded)) { - $parts[] = $encoded; - } + $parts[] = $this->encodeJson($part['input']); } } return implode("\n", array_filter($parts, 'is_string')); } + /** + * @param mixed $value Raw value. + */ + private function encodeJson($value): string + { + $encoded = function_exists('wp_json_encode') ? wp_json_encode($value) : json_encode($value); + return is_string($encoded) ? $encoded : ''; + } + /** * @param mixed $value Raw value. */ diff --git a/scripts/verify-homeboy-codebox-canary.sh b/scripts/verify-homeboy-codebox-canary.sh index 9b36b47..78e2b56 100755 --- a/scripts/verify-homeboy-codebox-canary.sh +++ b/scripts/verify-homeboy-codebox-canary.sh @@ -298,14 +298,6 @@ for secret_env in "${SECRET_ENVS[@]}"; do COMMAND+=(--secret-env "$secret_env") done -if [ -n "$CHANNEL" ]; then - COMMAND+=(--channel "$CHANNEL") -fi - -if [ -n "$THREAD" ]; then - COMMAND+=(--thread "$THREAD") -fi - if [ -n "$MODEL" ]; then COMMAND+=(--model "$MODEL") fi diff --git a/tests/homeboy-codebox-canary.sh b/tests/homeboy-codebox-canary.sh index 891d3ae..1d1a229 100755 --- a/tests/homeboy-codebox-canary.sh +++ b/tests/homeboy-codebox-canary.sh @@ -93,6 +93,15 @@ assert_contains() { fi } +assert_not_contains() { + local needle="$1" file="$2" + if grep -qF -- "$needle" "$file"; then + echo "FAIL: did not expect '$needle' in $file" + cat "$file" + exit 1 + fi +} + assert_json() { python3 - "$HOMEBOY_CAPTURE_CONFIG" <<'PY' import json @@ -109,6 +118,13 @@ assert config["provider"] == "openai" assert config["model"] == "gpt-4.1-mini" assert config["max_turns"] == 2 assert config["secret_env"] == ["OPENAI_API_KEY"] +assert config["routing"] == { + "channel": "canary-channel", + "thread": "canary-thread", + "client": "discord", + "ui": "kimaki", + "user_required": False, +} assert config["mounts"] == [ { "source": config["workspace_root"], @@ -136,12 +152,16 @@ OUTPUT="$TMP/output.log" --provider-plugin-path "$TMP/provider-openai" \ --model gpt-4.1-mini \ --max-turns 2 \ + --channel canary-channel \ + --thread canary-thread \ > "$OUTPUT" assert_contains "--secret-env" "$HOMEBOY_CAPTURE_ARGS" assert_contains "OPENAI_API_KEY" "$HOMEBOY_CAPTURE_ARGS" assert_contains "--backend" "$HOMEBOY_CAPTURE_ARGS" assert_contains "codebox" "$HOMEBOY_CAPTURE_ARGS" +assert_not_contains "--channel" "$HOMEBOY_CAPTURE_ARGS" +assert_not_contains "--thread" "$HOMEBOY_CAPTURE_ARGS" assert_contains "Canary validation passed" "$OUTPUT" assert_contains "Changed files: 0" "$OUTPUT" assert_json From 5686487ebd9c18aaf87def3e5e0c19e6abfccc76 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 7 Jun 2026 21:50:31 -0400 Subject: [PATCH 4/6] add claude-opus-4-8 model to Claude Code provider --- .../src/Provider/ClaudeCodeModelMetadataDirectory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeModelMetadataDirectory.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeModelMetadataDirectory.php index dface60..35d42e6 100644 --- a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeModelMetadataDirectory.php +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeModelMetadataDirectory.php @@ -66,7 +66,7 @@ private function getModelMap(): array ]; $models = []; - foreach (['claude-opus-4-7', 'claude-sonnet-4-6', 'claude-haiku-4-5'] as $modelId) { + foreach (['claude-opus-4-8', 'claude-opus-4-7', 'claude-sonnet-4-6', 'claude-haiku-4-5'] as $modelId) { $models[$modelId] = new ModelMetadata( $modelId, $modelId, From fdf79db04b0261b14d1dbb18697a22e3d686d98d Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 7 Jun 2026 22:37:58 -0400 Subject: [PATCH 5/6] Add structured tool calling to Claude Code provider The provider previously did text-only generation, so the agent's tools were never declared to Anthropic and the model improvised tool calls as freeform text that the runtime could not reliably parse. Declare the agent's function declarations as Anthropic `tools` with an `input_schema`, serialize FunctionCall/FunctionResponse message parts as `tool_use`/`tool_result` content blocks, and parse Anthropic `tool_use` response blocks back into FunctionCall message parts so the runtime's preferred structured tool-call path executes deterministically. Maps `stop_reason: tool_use` to FinishReasonEnum::toolCalls() and declares the functionDeclarations supported option. Adds a headless smoke test that mocks the HTTP transporter to assert the request carries structured tools and the response tool_use parses into a FunctionCall part. --- .../ClaudeCodeModelMetadataDirectory.php | 1 + .../ClaudeCodeTextGenerationModel.php | 127 ++++++++-- .../tests/structured-tools-smoke.php | 234 ++++++++++++++++++ 3 files changed, 341 insertions(+), 21 deletions(-) create mode 100644 carried-plugins/ai-provider-for-claude-code/tests/structured-tools-smoke.php diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeModelMetadataDirectory.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeModelMetadataDirectory.php index 35d42e6..02c4637 100644 --- a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeModelMetadataDirectory.php +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeModelMetadataDirectory.php @@ -60,6 +60,7 @@ private function getModelMap(): array new SupportedOption(OptionEnum::topP()), new SupportedOption(OptionEnum::outputMimeType(), ['text/plain', 'application/json']), new SupportedOption(OptionEnum::outputSchema()), + new SupportedOption(OptionEnum::functionDeclarations()), new SupportedOption(OptionEnum::customOptions()), new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]), new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::text()]]), diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php index bccce1d..cf85d51 100644 --- a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php @@ -21,6 +21,8 @@ use WordPress\AiClient\Results\DTO\GenerativeAiResult; use WordPress\AiClient\Results\DTO\TokenUsage; use WordPress\AiClient\Results\Enums\FinishReasonEnum; +use WordPress\AiClient\Tools\DTO\FunctionCall; +use WordPress\AiClient\Tools\DTO\FunctionResponse; /** * Text generation model for Claude Code using Anthropic Messages API. @@ -106,7 +108,13 @@ private function prepareGenerateTextParams(array $prompt): array $params['top_p'] = $topP; } - if ($config->getOutputMimeType() === 'application/json' && $config->getOutputSchema()) { + $functionDeclarations = $config->getFunctionDeclarations(); + if (is_array($functionDeclarations) && $functionDeclarations !== []) { + // Declare the agent's tools to Anthropic so the model returns + // structured `tool_use` blocks instead of improvising tool calls + // as freeform text. + $params['tools'] = $this->prepareToolsParam($functionDeclarations); + } elseif ($config->getOutputMimeType() === 'application/json' && $config->getOutputSchema()) { $params['tools'] = [ [ 'name' => 'response_schema', @@ -129,6 +137,29 @@ private function prepareGenerateTextParams(array $prompt): array return $params; } + /** + * Prepares the Anthropic `tools` parameter from function declarations. + * + * @param list<\WordPress\AiClient\Tools\DTO\FunctionDeclaration> $functionDeclarations Declarations. + * @return list> Anthropic tool definitions. + */ + private function prepareToolsParam(array $functionDeclarations): array + { + $tools = []; + foreach ($functionDeclarations as $functionDeclaration) { + $parameters = $functionDeclaration->getParameters(); + $tools[] = [ + 'name' => $functionDeclaration->getName(), + 'description' => $functionDeclaration->getDescription(), + 'input_schema' => is_array($parameters) && $parameters !== [] + ? $parameters + : ['type' => 'object', 'properties' => (object) []], + ]; + } + + return $tools; + } + /** * Converts prompt messages to Anthropic Messages format. * @@ -141,7 +172,7 @@ private function prepareMessages(array $messages): array foreach ($messages as $message) { $content = []; foreach ($message->getParts() as $part) { - $content[] = ['type' => 'text', 'text' => $this->prepareMessagePartText($part)]; + $content[] = $this->prepareMessagePartContent($part); } $output[] = [ @@ -153,22 +184,48 @@ private function prepareMessages(array $messages): array return $output; } - private function prepareMessagePartText(MessagePart $part): string + /** + * Converts a single message part to an Anthropic content block. + * + * @param MessagePart $part Message part. + * @return array Anthropic content block. + */ + private function prepareMessagePartContent(MessagePart $part): array { if ($part->getType()->isText()) { - return (string) $part->getText(); + return ['type' => 'text', 'text' => (string) $part->getText()]; } - $payload = ['type' => $part->getType()->value]; if ($part->getType()->isFunctionCall() && $part->getFunctionCall()) { - $payload['function_call'] = $part->getFunctionCall()->toArray(); - } elseif ($part->getType()->isFunctionResponse() && $part->getFunctionResponse()) { - $payload['function_response'] = $part->getFunctionResponse()->toArray(); - } elseif ($part->getType()->isFile() && $part->getFile()) { + $functionCall = $part->getFunctionCall(); + $arguments = $functionCall->getArgs(); + + return [ + 'type' => 'tool_use', + 'id' => (string) ($functionCall->getId() ?? $functionCall->getName() ?? ''), + 'name' => (string) ($functionCall->getName() ?? ''), + 'input' => is_array($arguments) ? $arguments : (object) [], + ]; + } + + if ($part->getType()->isFunctionResponse() && $part->getFunctionResponse()) { + $functionResponse = $part->getFunctionResponse(); + $response = $functionResponse->getResponse(); + + return [ + 'type' => 'tool_result', + 'tool_use_id' => (string) ($functionResponse->getId() ?? $functionResponse->getName() ?? ''), + 'content' => is_string($response) ? $response : $this->encodeJson($response), + ]; + } + + // Fallback for unsupported part types: serialize as text so context is preserved. + $payload = ['type' => $part->getType()->value]; + if ($part->getType()->isFile() && $part->getFile()) { $payload['file'] = $part->getFile()->toArray(); } - return $this->encodeJson($payload); + return ['type' => 'text', 'text' => $this->encodeJson($payload)]; } private function roleToAnthropicRole(MessageRoleEnum $role): string @@ -186,9 +243,9 @@ private function parseResponseToResult(Response $response): GenerativeAiResult throw ResponseException::fromMissingData('Claude Code', 'content'); } - $text = $this->extractText($data); - if ($text === '') { - throw ResponseException::fromMissingData('Claude Code', 'content.text'); + $parts = $this->parseResponseParts($data); + if ($parts === []) { + throw ResponseException::fromMissingData('Claude Code', 'content'); } $usage = isset($data['usage']) && is_array($data['usage']) ? $data['usage'] : []; @@ -197,7 +254,7 @@ private function parseResponseToResult(Response $response): GenerativeAiResult return new GenerativeAiResult( isset($data['id']) && is_string($data['id']) ? $data['id'] : '', - [new Candidate(new ModelMessage([new MessagePart($text)]), FinishReasonEnum::stop())], + [new Candidate(new ModelMessage($parts), $this->mapFinishReason($data['stop_reason'] ?? null))], new TokenUsage($inputTokens, $outputTokens, $inputTokens + $outputTokens), $this->providerMetadata(), $this->metadata(), @@ -206,27 +263,55 @@ private function parseResponseToResult(Response $response): GenerativeAiResult } /** + * Converts Anthropic response content blocks into message parts. + * * @param array $data Response data. + * @return list Message parts. */ - private function extractText(array $data): string + private function parseResponseParts(array $data): array { $parts = []; if (!isset($data['content']) || !is_array($data['content'])) { - return ''; + return $parts; } foreach ($data['content'] as $part) { if (!is_array($part)) { continue; } - if (($part['type'] ?? '') === 'text' && isset($part['text']) && is_string($part['text'])) { - $parts[] = $part['text']; - } elseif (($part['type'] ?? '') === 'tool_use' && isset($part['input'])) { - $parts[] = $this->encodeJson($part['input']); + + $type = $part['type'] ?? ''; + if ($type === 'text' && isset($part['text']) && is_string($part['text']) && $part['text'] !== '') { + $parts[] = new MessagePart($part['text']); + } elseif ($type === 'tool_use') { + $name = isset($part['name']) && is_string($part['name']) ? $part['name'] : ''; + if ($name === '') { + continue; + } + $input = isset($part['input']) && is_array($part['input']) ? $part['input'] : []; + $id = isset($part['id']) && is_string($part['id']) ? $part['id'] : null; + $parts[] = new MessagePart(new FunctionCall($id, $name, $input)); } } - return implode("\n", array_filter($parts, 'is_string')); + return $parts; + } + + /** + * Maps an Anthropic stop reason to a finish reason. + * + * @param mixed $stopReason Anthropic stop reason. + */ + private function mapFinishReason($stopReason): FinishReasonEnum + { + if ($stopReason === 'tool_use') { + return FinishReasonEnum::toolCalls(); + } + if ($stopReason === 'max_tokens') { + return FinishReasonEnum::length(); + } + + return FinishReasonEnum::stop(); } /** diff --git a/carried-plugins/ai-provider-for-claude-code/tests/structured-tools-smoke.php b/carried-plugins/ai-provider-for-claude-code/tests/structured-tools-smoke.php new file mode 100644 index 0000000..bc0b898 --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/tests/structured-tools-smoke.php @@ -0,0 +1,234 @@ +setHttpTransporter( + new class ($capturedRequests) implements HttpTransporterInterface { + /** @var array */ + private array $captured; + + /** + * @param array $captured + */ + public function __construct(array &$captured) + { + $this->captured = &$captured; + } + + public function send(Request $request, ?RequestOptions $options = null): Response + { + $this->captured[] = $request; + + $body = json_encode( + [ + 'id' => 'msg_test', + 'type' => 'message', + 'role' => 'assistant', + 'stop_reason' => 'tool_use', + 'content' => [ + ['type' => 'text', 'text' => 'Writing the file now.'], + [ + 'type' => 'tool_use', + 'id' => 'toolu_123', + 'name' => 'workspace_write', + 'input' => ['path' => 'X.md', 'content' => '# hi'], + ], + ], + 'usage' => ['input_tokens' => 5, 'output_tokens' => 7], + ] + ); + + return new Response(200, ['Content-Type' => 'application/json'], (string) $body); + } + } +); + +putenv('AI_PROVIDER_CLAUDE_CODE_ACCESS_TOKEN=test-access-token'); +putenv('AI_PROVIDER_CLAUDE_CODE_REFRESH_TOKEN=test-refresh-token'); +putenv('AI_PROVIDER_CLAUDE_CODE_EXPIRES_AT=' . (time() + 3600)); + +$registry->registerProvider(ClaudeCodeProvider::class); +$registry->setProviderRequestAuthentication( + ClaudeCodeProvider::class, + new \ExtraChill\ClaudeCodeAiProvider\Provider\ClaudeCodeRequestAuthentication( + new \ExtraChill\ClaudeCodeAiProvider\Provider\ClaudeCodeTokenStore(), + new \ExtraChill\ClaudeCodeAiProvider\Provider\ClaudeCodeOAuthClient( + new \ExtraChill\ClaudeCodeAiProvider\Provider\ClaudeCodeTokenStore() + ) + ) +); + +$check($registry->isProviderConfigured('claude-code') === true, 'provider is configured with OAuth tokens'); + +$model = $registry->getProviderModel('claude-code', 'claude-opus-4-8'); + +$config = ModelConfig::fromArray([]); +$config->setFunctionDeclarations([ + new FunctionDeclaration( + 'workspace_write', + 'Write a file to the workspace.', + [ + 'type' => 'object', + 'properties' => [ + 'path' => ['type' => 'string'], + 'content' => ['type' => 'string'], + ], + 'required' => ['path', 'content'], + ] + ), +]); +$model->setConfig($config); + +// Prompt includes a prior tool call + response to exercise tool_use/tool_result serialization. +$prompt = [ + new UserMessage([new MessagePart('create X.md')]), + new ModelMessage([new MessagePart(new FunctionCall('toolu_prev', 'workspace_ls', ['path' => '.']))]), + new Message( + MessageRoleEnum::user(), + [new MessagePart(new FunctionResponse('toolu_prev', 'workspace_ls', ['files' => ['README.md']]))] + ), +]; + +$result = $model->generateTextResult($prompt); + +// --- Assert the outgoing request declared structured tools --- +$check(count($capturedRequests) === 1, 'exactly one request was dispatched'); +$requestData = $capturedRequests[0]->getData(); +$check(is_array($requestData), 'request body is an array'); + +$tools = $requestData['tools'] ?? null; +$check(is_array($tools) && count($tools) === 1, 'request declares one tool'); +$check(($tools[0]['name'] ?? null) === 'workspace_write', 'tool name is forwarded'); +$check(($tools[0]['description'] ?? null) === 'Write a file to the workspace.', 'tool description is forwarded'); +$check( + isset($tools[0]['input_schema']['properties']['path']), + 'tool parameters are forwarded as input_schema' +); + +// --- Assert FunctionCall/FunctionResponse parts became tool_use/tool_result blocks --- +$messages = $requestData['messages'] ?? []; +$blocks = []; +foreach ($messages as $message) { + foreach (($message['content'] ?? []) as $contentBlock) { + $blocks[] = $contentBlock['type'] ?? ''; + } +} +$check(in_array('tool_use', $blocks, true), 'prior FunctionCall serialized as tool_use block'); +$check(in_array('tool_result', $blocks, true), 'prior FunctionResponse serialized as tool_result block'); + +// --- Assert the response tool_use parsed into a FunctionCall message part --- +$candidates = $result->getCandidates(); +$check(count($candidates) === 1, 'result has one candidate'); +$parts = $candidates[0]->getMessage()->getParts(); +$functionCallPart = null; +foreach ($parts as $part) { + if ($part->getType()->isFunctionCall()) { + $functionCallPart = $part; + break; + } +} +$check($functionCallPart !== null, 'response tool_use parsed into a FunctionCall part'); +if ($functionCallPart !== null) { + $functionCall = $functionCallPart->getFunctionCall(); + $check($functionCall->getName() === 'workspace_write', 'parsed FunctionCall has correct name'); + $check($functionCall->getId() === 'toolu_123', 'parsed FunctionCall preserves id'); + $args = $functionCall->getArgs(); + $check(is_array($args) && ($args['path'] ?? null) === 'X.md', 'parsed FunctionCall has decoded arguments'); +} + +if ($failures > 0) { + fwrite(STDERR, "\n{$failures} structured-tools assertion(s) failed.\n"); + exit(1); +} + +echo "\nOK: {$passes} structured-tools assertions passed.\n"; From 8d1a9a6c46fec2ad5382ade2bb600580f6c97de0 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 7 Jun 2026 23:27:23 -0400 Subject: [PATCH 6/6] Encode empty tool_use input as a JSON object Anthropic rejects a Messages request with `tool_use.input: Input should be an object` when a serialized assistant tool call carried empty or list-shaped arguments, because an empty PHP array encodes as `[]` rather than `{}`. Coerce function-call arguments to an object so no-argument tool calls round-trip correctly, and cover the case in the structured-tools smoke test. --- .../ClaudeCodeTextGenerationModel.php | 28 +++++++++++++++++-- .../tests/structured-tools-smoke.php | 20 ++++++++++++- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php index cf85d51..9620789 100644 --- a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php @@ -198,13 +198,14 @@ private function prepareMessagePartContent(MessagePart $part): array if ($part->getType()->isFunctionCall() && $part->getFunctionCall()) { $functionCall = $part->getFunctionCall(); - $arguments = $functionCall->getArgs(); return [ 'type' => 'tool_use', 'id' => (string) ($functionCall->getId() ?? $functionCall->getName() ?? ''), 'name' => (string) ($functionCall->getName() ?? ''), - 'input' => is_array($arguments) ? $arguments : (object) [], + // Anthropic requires tool_use.input to be a JSON object. An empty + // or list-shaped arguments value must encode as `{}`, not `[]`. + 'input' => $this->toInputObject($functionCall->getArgs()), ]; } @@ -314,6 +315,29 @@ private function mapFinishReason($stopReason): FinishReasonEnum return FinishReasonEnum::stop(); } + /** + * Normalizes function-call arguments into a JSON object for `tool_use.input`. + * + * Anthropic rejects a request when `tool_use.input` is not an object. An + * associative array encodes as an object, but an empty or list-shaped array + * encodes as `[]`, so those are coerced to an empty object. + * + * @param mixed $arguments Raw function-call arguments. + * @return object|array Object-encodable input. + */ + private function toInputObject($arguments) + { + if (is_array($arguments) && $arguments !== [] && array_keys($arguments) !== range(0, count($arguments) - 1)) { + return $arguments; + } + + if (is_object($arguments)) { + return $arguments; + } + + return (object) []; + } + /** * @param mixed $value Raw value. */ diff --git a/carried-plugins/ai-provider-for-claude-code/tests/structured-tools-smoke.php b/carried-plugins/ai-provider-for-claude-code/tests/structured-tools-smoke.php index bc0b898..e5b131c 100644 --- a/carried-plugins/ai-provider-for-claude-code/tests/structured-tools-smoke.php +++ b/carried-plugins/ai-provider-for-claude-code/tests/structured-tools-smoke.php @@ -169,7 +169,8 @@ public function send(Request $request, ?RequestOptions $options = null): Respons ]); $model->setConfig($config); -// Prompt includes a prior tool call + response to exercise tool_use/tool_result serialization. +// Prompt includes a prior tool call + response to exercise tool_use/tool_result +// serialization, plus a no-argument tool call to verify empty input encodes as `{}`. $prompt = [ new UserMessage([new MessagePart('create X.md')]), new ModelMessage([new MessagePart(new FunctionCall('toolu_prev', 'workspace_ls', ['path' => '.']))]), @@ -177,6 +178,11 @@ public function send(Request $request, ?RequestOptions $options = null): Respons MessageRoleEnum::user(), [new MessagePart(new FunctionResponse('toolu_prev', 'workspace_ls', ['files' => ['README.md']]))] ), + new ModelMessage([new MessagePart(new FunctionCall('toolu_noargs', 'workspace_capabilities', []))]), + new Message( + MessageRoleEnum::user(), + [new MessagePart(new FunctionResponse('toolu_noargs', 'workspace_capabilities', ['ok' => true]))] + ), ]; $result = $model->generateTextResult($prompt); @@ -206,6 +212,18 @@ public function send(Request $request, ?RequestOptions $options = null): Respons $check(in_array('tool_use', $blocks, true), 'prior FunctionCall serialized as tool_use block'); $check(in_array('tool_result', $blocks, true), 'prior FunctionResponse serialized as tool_result block'); +// Anthropic requires tool_use.input to be a JSON object; a no-argument call must +// encode as `{}`, never `[]`. +$noArgsInputJson = null; +foreach ($messages as $message) { + foreach (($message['content'] ?? []) as $contentBlock) { + if (($contentBlock['type'] ?? '') === 'tool_use' && ($contentBlock['name'] ?? '') === 'workspace_capabilities') { + $noArgsInputJson = json_encode($contentBlock['input']); + } + } +} +$check($noArgsInputJson === '{}', 'no-argument tool_use input encodes as an empty object'); + // --- Assert the response tool_use parsed into a FunctionCall message part --- $candidates = $result->getCandidates(); $check(count($candidates) === 1, 'result has one candidate');