716 lines
34 KiB
PHP
716 lines
34 KiB
PHP
<?php
|
|
/**
|
|
* Tool calling for the AI gateway (agent phase P1).
|
|
*
|
|
* raijuAiChatWithTools() sends one model turn with tool definitions and returns
|
|
* either requested tool calls or a final answer. It speaks the OpenAI-compatible
|
|
* format (also OpenRouter and local servers) and the Anthropic Messages API, while
|
|
* callers only see one internal message format:
|
|
*
|
|
* {role: system|user|assistant|tool, content: string,
|
|
* toolCalls?: [{id, name, arguments: array}] (assistant)
|
|
* toolCallId?: string, isError?: bool} (tool)
|
|
*
|
|
* Assistant messages may carry opaque 'reasoningItems' (OpenAI Responses API, encrypted so nothing
|
|
* is stored at the provider); they go back to the provider unchanged and are never shown.
|
|
*
|
|
* raijuAiChat(), raijuAiChatStream() and raijuAiCleanMessages() are untouched.
|
|
*
|
|
* Accounting: every call is its own top-level request (ledger reservation). A call
|
|
* nested inside another AI request would lose its usage, so it is refused.
|
|
*/
|
|
|
|
const RAIJU_AGENT_MESSAGE_CHARS = 12000;
|
|
const RAIJU_AGENT_MAX_MESSAGES = 60;
|
|
const RAIJU_AGENT_MAX_TOOLS = 40;
|
|
const RAIJU_AGENT_MAX_ARGUMENT_BYTES = 200000;
|
|
const RAIJU_AGENT_MAX_REASONING_BYTES = 200000;
|
|
|
|
/** Catalog key of the provider behind the saved settings. */
|
|
function raijuAiProviderKey(array $settings): string {
|
|
if (raijuAiIsKieSettings($settings)) return 'kie';
|
|
if (raijuAiIsAnthropicSettings($settings)) return 'anthropic';
|
|
if (raijuAiIsOpenRouterSettings($settings)) return 'openrouter';
|
|
return 'openai-compatible';
|
|
}
|
|
|
|
function raijuAiSupportsToolCalling(array $settings): bool {
|
|
return !empty(raijuAiModelCatalog()[raijuAiProviderKey($settings)]['capabilities']['toolCalling']);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Messages
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function raijuAgentValidName(string $name): bool {
|
|
return preg_match('/^[A-Za-z0-9_-]{1,64}$/', $name) === 1;
|
|
}
|
|
|
|
function raijuAgentNewCallId(): string {
|
|
return 'call_' . bin2hex(random_bytes(8));
|
|
}
|
|
|
|
/** Encrypted reasoning items of the Responses API, passed through; anything else is dropped. */
|
|
function raijuAgentCleanReasoningItems($items): array {
|
|
if (!is_array($items) || !array_is_list($items)) return [];
|
|
$clean = [];
|
|
foreach ($items as $item) {
|
|
if (!is_array($item) || ($item['type'] ?? '') !== 'reasoning' || !is_string($item['encrypted_content'] ?? null) || $item['encrypted_content'] === '') continue;
|
|
$entry = ['type' => 'reasoning', 'encrypted_content' => $item['encrypted_content'], 'summary' => is_array($item['summary'] ?? null) ? array_values($item['summary']) : []];
|
|
if (is_string($item['id'] ?? null) && preg_match('/^[A-Za-z0-9_-]{1,100}$/', $item['id'])) $entry['id'] = $item['id'];
|
|
$clean[] = $entry;
|
|
}
|
|
return strlen((string)json_encode($clean)) > RAIJU_AGENT_MAX_REASONING_BYTES ? [] : $clean;
|
|
}
|
|
|
|
/**
|
|
* Validate and normalise an agent transcript.
|
|
*
|
|
* Keeps tool roles, caps every message at about 12k characters and enforces the
|
|
* pairing providers demand: each assistant tool call is answered by exactly one
|
|
* tool message directly after it, and a tool message always belongs to a call.
|
|
* Trimming to $maxMessages (system messages and the first user message stay) drops
|
|
* whole exchanges only, so a tool result never loses its tool call or vice versa.
|
|
*
|
|
* @throws InvalidArgumentException when the history is inconsistent
|
|
*/
|
|
function raijuAgentCleanMessages(array $messages, int $maxMessages = RAIJU_AGENT_MAX_MESSAGES): array {
|
|
$clean = [];
|
|
foreach ($messages as $message) {
|
|
if (!is_array($message)) continue;
|
|
$role = (string)($message['role'] ?? 'user');
|
|
if (!in_array($role, ['system', 'user', 'assistant', 'tool'], true)) {
|
|
throw new InvalidArgumentException("Unknown message role '{$role}'.");
|
|
}
|
|
$content = (string)($message['content'] ?? '');
|
|
if ($role !== 'tool') $content = trim($content);
|
|
if (mb_strlen($content, 'UTF-8') > RAIJU_AGENT_MESSAGE_CHARS) {
|
|
$content = raijuAiUtf8Prefix($content, RAIJU_AGENT_MESSAGE_CHARS) . "\n[truncated]";
|
|
}
|
|
$entry = ['role' => $role, 'content' => $content];
|
|
|
|
if ($role === 'assistant' && !empty($message['toolCalls'])) {
|
|
$calls = [];
|
|
foreach ((array)$message['toolCalls'] as $call) {
|
|
$id = is_array($call) ? (string)($call['id'] ?? '') : '';
|
|
$name = is_array($call) ? (string)($call['name'] ?? '') : '';
|
|
$arguments = is_array($call) && is_array($call['arguments'] ?? null) ? $call['arguments'] : [];
|
|
if (!raijuAgentValidName($id) || !raijuAgentValidName($name)) {
|
|
throw new InvalidArgumentException('A tool call needs a valid id and name.');
|
|
}
|
|
$calls[] = ['id' => $id, 'name' => $name, 'arguments' => $arguments];
|
|
}
|
|
$entry['toolCalls'] = $calls;
|
|
$reasoning = raijuAgentCleanReasoningItems($message['reasoningItems'] ?? []);
|
|
if ($reasoning) $entry['reasoningItems'] = $reasoning;
|
|
} elseif ($role === 'tool') {
|
|
$id = (string)($message['toolCallId'] ?? '');
|
|
if (!raijuAgentValidName($id)) {
|
|
throw new InvalidArgumentException('A tool message needs the id of its tool call.');
|
|
}
|
|
$entry['toolCallId'] = $id;
|
|
$entry['isError'] = !empty($message['isError']);
|
|
if ($content === '') $entry['content'] = '(empty)';
|
|
} elseif ($content === '') {
|
|
continue;
|
|
}
|
|
$clean[] = $entry;
|
|
}
|
|
|
|
// Pairing: group each assistant tool call exchange.
|
|
$groups = [];
|
|
$pending = [];
|
|
$seen = [];
|
|
foreach ($clean as $entry) {
|
|
if ($pending) {
|
|
if ($entry['role'] !== 'tool' || !isset($pending[$entry['toolCallId']])) {
|
|
throw new InvalidArgumentException('Tool call history is inconsistent: a tool call has no result.');
|
|
}
|
|
unset($pending[$entry['toolCallId']]);
|
|
$groups[count($groups) - 1][] = $entry;
|
|
continue;
|
|
}
|
|
if ($entry['role'] === 'tool') {
|
|
throw new InvalidArgumentException('Tool call history is inconsistent: a tool result has no tool call.');
|
|
}
|
|
if (!empty($entry['toolCalls'])) {
|
|
foreach ($entry['toolCalls'] as $call) {
|
|
if (isset($seen[$call['id']])) {
|
|
throw new InvalidArgumentException('Tool call history is inconsistent: duplicate tool call id.');
|
|
}
|
|
$seen[$call['id']] = true;
|
|
$pending[$call['id']] = true;
|
|
}
|
|
}
|
|
$groups[] = [$entry];
|
|
}
|
|
if ($pending) {
|
|
throw new InvalidArgumentException('Tool call history is inconsistent: a tool call has no result.');
|
|
}
|
|
|
|
// Whole-exchange trimming. System messages and the first user message are pinned.
|
|
$pinned = [];
|
|
$firstUser = false;
|
|
foreach ($groups as $index => $group) {
|
|
if ($group[0]['role'] === 'system' || (!$firstUser && $group[0]['role'] === 'user')) {
|
|
$pinned[$index] = true;
|
|
if ($group[0]['role'] === 'user') $firstUser = true;
|
|
}
|
|
}
|
|
$count = static fn(array $keep): int => array_sum(array_map(static fn($i) => count($groups[$i]), array_keys($keep)));
|
|
$keep = array_fill_keys(array_keys($groups), true);
|
|
if ($maxMessages > 0) {
|
|
foreach (array_keys($groups) as $index) {
|
|
if ($count($keep) <= $maxMessages) break;
|
|
if (!isset($pinned[$index])) unset($keep[$index]);
|
|
}
|
|
}
|
|
// Nothing but a user turn may open the conversation (after any system messages).
|
|
$result = [];
|
|
$opened = false;
|
|
foreach (array_keys($keep) as $index) {
|
|
$role = $groups[$index][0]['role'];
|
|
if (!$opened && $role !== 'system') {
|
|
if ($role !== 'user') continue;
|
|
$opened = true;
|
|
}
|
|
foreach ($groups[$index] as $entry) $result[] = $entry;
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tool definitions and argument validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** @return array<string,array{name:string,description:string,parameters:array}> keyed by name */
|
|
function raijuAgentNormalizeTools(array $tools): array {
|
|
if (count($tools) > RAIJU_AGENT_MAX_TOOLS) {
|
|
throw new InvalidArgumentException('Too many tools.');
|
|
}
|
|
$normalized = [];
|
|
foreach ($tools as $tool) {
|
|
$name = is_array($tool) ? (string)($tool['name'] ?? '') : '';
|
|
if (!raijuAgentValidName($name)) {
|
|
throw new InvalidArgumentException('A tool needs a name of letters, digits, "_" or "-" (max. 64).');
|
|
}
|
|
if (isset($normalized[$name])) {
|
|
throw new InvalidArgumentException("Duplicate tool '{$name}'.");
|
|
}
|
|
$parameters = is_array($tool['parameters'] ?? null) ? $tool['parameters'] : [];
|
|
$parameters['type'] = 'object';
|
|
$normalized[$name] = [
|
|
'name' => $name,
|
|
'description' => raijuAiUtf8Prefix(trim((string)($tool['description'] ?? '')), 1000),
|
|
'parameters' => $parameters
|
|
];
|
|
}
|
|
return $normalized;
|
|
}
|
|
|
|
/** JSON Schema for the provider: empty "properties" must encode as {} and "required" must not be []. */
|
|
function raijuAgentProviderSchema(array $schema) {
|
|
if (array_key_exists('required', $schema) && !$schema['required']) {
|
|
unset($schema['required']);
|
|
}
|
|
if (array_key_exists('properties', $schema)) {
|
|
$properties = [];
|
|
foreach ((array)$schema['properties'] as $key => $child) {
|
|
$properties[$key] = is_array($child) ? raijuAgentProviderSchema($child) : $child;
|
|
}
|
|
$schema['properties'] = $properties ?: new stdClass();
|
|
} elseif (($schema['type'] ?? '') === 'object') {
|
|
$schema['properties'] = new stdClass();
|
|
}
|
|
if (is_array($schema['items'] ?? null)) {
|
|
$schema['items'] = raijuAgentProviderSchema($schema['items']);
|
|
}
|
|
foreach (['anyOf', 'oneOf', 'allOf'] as $key) {
|
|
if (is_array($schema[$key] ?? null)) {
|
|
$schema[$key] = array_map(static fn($child) => is_array($child) ? raijuAgentProviderSchema($child) : $child, $schema[$key]);
|
|
}
|
|
}
|
|
return $schema;
|
|
}
|
|
|
|
function raijuAgentIsObjectValue($value): bool {
|
|
return is_array($value) && ($value === [] || !array_is_list($value));
|
|
}
|
|
|
|
/**
|
|
* Small JSON Schema validator for tool arguments. Supports type, required,
|
|
* properties, additionalProperties:false, enum, minLength/maxLength, pattern,
|
|
* minimum/maximum, items and minItems/maxItems.
|
|
*
|
|
* @return string[] one message per violation (empty = valid)
|
|
*/
|
|
function raijuAgentValidateSchema(array $schema, $value, string $path = 'arguments'): array {
|
|
$errors = [];
|
|
$type = $schema['type'] ?? null;
|
|
$types = is_array($type) ? $type : ($type === null ? [] : [$type]);
|
|
if ($types) {
|
|
$matches = false;
|
|
foreach ($types as $candidate) {
|
|
$matches = $matches || match ($candidate) {
|
|
'string' => is_string($value),
|
|
'integer' => is_int($value) || (is_float($value) && floor($value) === $value && is_finite($value)),
|
|
'number' => is_int($value) || is_float($value),
|
|
'boolean' => is_bool($value),
|
|
'null' => $value === null,
|
|
'array' => is_array($value) && array_is_list($value),
|
|
'object' => raijuAgentIsObjectValue($value),
|
|
default => false
|
|
};
|
|
}
|
|
if (!$matches) {
|
|
return ["{$path} must be of type " . implode('|', $types) . '.'];
|
|
}
|
|
}
|
|
if (isset($schema['enum']) && is_array($schema['enum']) && !in_array($value, $schema['enum'], true)) {
|
|
$errors[] = "{$path} must be one of: " . implode(', ', array_map(static fn($v) => json_encode($v, JSON_UNESCAPED_UNICODE), array_slice($schema['enum'], 0, 12))) . '.';
|
|
}
|
|
if (is_string($value)) {
|
|
$length = mb_strlen($value, 'UTF-8');
|
|
if (isset($schema['maxLength']) && $length > (int)$schema['maxLength']) $errors[] = "{$path} is longer than {$schema['maxLength']} characters.";
|
|
if (isset($schema['minLength']) && $length < (int)$schema['minLength']) $errors[] = "{$path} is shorter than {$schema['minLength']} characters.";
|
|
if (isset($schema['pattern']) && @preg_match('~' . str_replace('~', '\~', (string)$schema['pattern']) . '~u', $value) !== 1) {
|
|
$errors[] = "{$path} does not match the required pattern.";
|
|
}
|
|
}
|
|
if ((is_int($value) || is_float($value)) && !is_bool($value)) {
|
|
if (isset($schema['minimum']) && $value < $schema['minimum']) $errors[] = "{$path} must be at least {$schema['minimum']}.";
|
|
if (isset($schema['maximum']) && $value > $schema['maximum']) $errors[] = "{$path} must be at most {$schema['maximum']}.";
|
|
}
|
|
if (is_array($value) && array_is_list($value) && $value !== []) {
|
|
if (isset($schema['maxItems']) && count($value) > (int)$schema['maxItems']) $errors[] = "{$path} has more than {$schema['maxItems']} items.";
|
|
if (is_array($schema['items'] ?? null)) {
|
|
foreach ($value as $index => $item) {
|
|
$errors = array_merge($errors, raijuAgentValidateSchema($schema['items'], $item, "{$path}[{$index}]"));
|
|
}
|
|
}
|
|
}
|
|
if (is_array($value) && array_is_list($value) && isset($schema['minItems']) && count($value) < (int)$schema['minItems']) {
|
|
$errors[] = "{$path} needs at least {$schema['minItems']} items.";
|
|
}
|
|
if (raijuAgentIsObjectValue($value)) {
|
|
$properties = is_array($schema['properties'] ?? null) ? $schema['properties'] : [];
|
|
foreach ((array)($schema['required'] ?? []) as $required) {
|
|
if (!array_key_exists($required, $value)) $errors[] = "{$path}.{$required} is required.";
|
|
}
|
|
foreach ($value as $key => $item) {
|
|
if (isset($properties[$key]) && is_array($properties[$key])) {
|
|
$errors = array_merge($errors, raijuAgentValidateSchema($properties[$key], $item, "{$path}.{$key}"));
|
|
} elseif (($schema['additionalProperties'] ?? true) === false) {
|
|
$errors[] = "{$path}.{$key} is not an allowed argument.";
|
|
}
|
|
}
|
|
}
|
|
return $errors;
|
|
}
|
|
|
|
/**
|
|
* Turn the provider's raw tool calls into {id, name, arguments, error}. Ids the
|
|
* provider omitted or that are unusable get a generated one; invalid JSON, unknown
|
|
* tools and schema violations become a per-call error the loop returns to the model.
|
|
*
|
|
* @param array<int,array{id:string,name:string,arguments:mixed}> $raw arguments: JSON string or decoded array
|
|
*/
|
|
function raijuAgentFinalizeToolCalls(array $raw, array $tools): array {
|
|
$calls = [];
|
|
$usedIds = [];
|
|
foreach ($raw as $call) {
|
|
$id = (string)($call['id'] ?? '');
|
|
if (!raijuAgentValidName($id) || isset($usedIds[$id])) $id = raijuAgentNewCallId();
|
|
$usedIds[$id] = true;
|
|
$name = (string)($call['name'] ?? '');
|
|
$arguments = [];
|
|
$error = null;
|
|
|
|
$rawArguments = $call['arguments'] ?? [];
|
|
if (is_string($rawArguments)) {
|
|
$trimmed = trim($rawArguments);
|
|
if (strlen($trimmed) > RAIJU_AGENT_MAX_ARGUMENT_BYTES) {
|
|
$error = 'Arguments are too large.';
|
|
} elseif ($trimmed === '') {
|
|
$rawArguments = [];
|
|
} else {
|
|
$decoded = json_decode($trimmed, true);
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
$error = 'Arguments are not valid JSON (' . json_last_error_msg() . ').';
|
|
} else {
|
|
$rawArguments = $decoded;
|
|
}
|
|
}
|
|
}
|
|
if ($error === null && !raijuAgentIsObjectValue($rawArguments)) {
|
|
$error = 'Arguments must be a JSON object.';
|
|
}
|
|
if ($error === null) {
|
|
$arguments = $rawArguments;
|
|
if (!raijuAgentValidName($name) || !isset($tools[$name])) {
|
|
$error = "Unknown tool '" . raijuAiUtf8Prefix($name, 64) . "'. Available tools: " . implode(', ', array_keys($tools)) . '.';
|
|
} else {
|
|
$violations = raijuAgentValidateSchema($tools[$name]['parameters'], $arguments);
|
|
if ($violations) $error = 'Invalid arguments: ' . implode(' ', array_slice($violations, 0, 5));
|
|
}
|
|
}
|
|
$calls[] = ['id' => $id, 'name' => raijuAgentValidName($name) ? $name : 'invalid', 'arguments' => $error === null ? $arguments : [], 'error' => $error];
|
|
}
|
|
return $calls;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Provider mappings
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function raijuAgentOpenAiMessages(array $messages): array {
|
|
$mapped = [];
|
|
foreach ($messages as $message) {
|
|
if ($message['role'] === 'tool') {
|
|
$mapped[] = ['role' => 'tool', 'tool_call_id' => $message['toolCallId'], 'content' => $message['content']];
|
|
} elseif ($message['role'] === 'assistant' && !empty($message['toolCalls'])) {
|
|
$mapped[] = [
|
|
'role' => 'assistant',
|
|
'content' => $message['content'] !== '' ? $message['content'] : null,
|
|
'tool_calls' => array_map(static fn($call) => [
|
|
'id' => $call['id'],
|
|
'type' => 'function',
|
|
'function' => ['name' => $call['name'], 'arguments' => json_encode($call['arguments'] ?: new stdClass(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)]
|
|
], $message['toolCalls'])
|
|
];
|
|
} else {
|
|
$mapped[] = ['role' => $message['role'], 'content' => $message['content']];
|
|
}
|
|
}
|
|
return $mapped;
|
|
}
|
|
|
|
function raijuAgentOpenAiBody(array $messages, array $tools, string $model, int $maxOutput, float $temperature): array {
|
|
return [
|
|
'model' => $model,
|
|
'messages' => raijuAgentOpenAiMessages($messages),
|
|
'tools' => array_values(array_map(static fn($tool) => [
|
|
'type' => 'function',
|
|
'function' => [
|
|
'name' => $tool['name'],
|
|
'description' => $tool['description'],
|
|
'parameters' => raijuAgentProviderSchema($tool['parameters'])
|
|
]
|
|
], $tools)),
|
|
'tool_choice' => 'auto',
|
|
'max_tokens' => $maxOutput,
|
|
'temperature' => $temperature,
|
|
'stream' => false
|
|
];
|
|
}
|
|
|
|
/** @return array{text:string,calls:array,stopReason:string,inputTokens:?int,outputTokens:?int} */
|
|
function raijuAgentParseOpenAiResponse(array $response): array {
|
|
$choice = is_array($response['choices'][0] ?? null) ? $response['choices'][0] : [];
|
|
$message = is_array($choice['message'] ?? null) ? $choice['message'] : [];
|
|
$raw = [];
|
|
foreach ((is_array($message['tool_calls'] ?? null) ? $message['tool_calls'] : []) as $call) {
|
|
if (!is_array($call)) continue;
|
|
$function = is_array($call['function'] ?? null) ? $call['function'] : [];
|
|
$raw[] = ['id' => (string)($call['id'] ?? ''), 'name' => (string)($function['name'] ?? ''), 'arguments' => $function['arguments'] ?? ''];
|
|
}
|
|
$finish = (string)($choice['finish_reason'] ?? '');
|
|
return [
|
|
'text' => trim((string)($message['content'] ?? '')),
|
|
'calls' => $raw,
|
|
'stopReason' => $finish === 'length' ? 'length' : ($raw ? 'tool_calls' : 'stop'),
|
|
'inputTokens' => isset($response['usage']['prompt_tokens']) ? (int)$response['usage']['prompt_tokens'] : null,
|
|
'outputTokens' => isset($response['usage']['completion_tokens']) ? (int)$response['usage']['completion_tokens'] : null,
|
|
'usage' => is_array($response['usage'] ?? null) ? $response['usage'] : null,
|
|
'reasoning' => !empty($message['reasoning'])
|
|
];
|
|
}
|
|
|
|
/**
|
|
* OpenAI itself is spoken to through the Responses API (reasoning models cannot combine function tools
|
|
* with reasoning on chat/completions). Other OpenAI-compatible hosts use chat/completions unless a
|
|
* rejected request taught us that the model needs /responses.
|
|
*/
|
|
function raijuAgentUsesResponsesApi(array $settings, string $model): bool {
|
|
if (raijuAiProviderKey($settings) !== 'openai-compatible') return false;
|
|
if (strtolower((string)parse_url((string)($settings['baseUrl'] ?? ''), PHP_URL_HOST)) === 'api.openai.com') return true;
|
|
return (raijuAiKnownQuirks($settings, $model)['api'] ?? '') === 'responses';
|
|
}
|
|
|
|
/** @return array{0:string,1:array} instructions and input items */
|
|
function raijuAgentResponsesInput(array $messages): array {
|
|
$instructions = [];
|
|
$input = [];
|
|
foreach ($messages as $message) {
|
|
if ($message['role'] === 'system') {
|
|
$instructions[] = $message['content'];
|
|
} elseif ($message['role'] === 'tool') {
|
|
$input[] = ['type' => 'function_call_output', 'call_id' => $message['toolCallId'], 'output' => $message['content']];
|
|
} elseif ($message['role'] === 'assistant') {
|
|
foreach ($message['reasoningItems'] ?? [] as $item) $input[] = $item;
|
|
if ($message['content'] !== '') $input[] = ['role' => 'assistant', 'content' => $message['content']];
|
|
foreach ($message['toolCalls'] ?? [] as $call) {
|
|
$input[] = [
|
|
'type' => 'function_call',
|
|
'call_id' => $call['id'],
|
|
'name' => $call['name'],
|
|
'arguments' => json_encode($call['arguments'] ?: new stdClass(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
|
];
|
|
}
|
|
} else {
|
|
$input[] = ['role' => 'user', 'content' => $message['content']];
|
|
}
|
|
}
|
|
return [implode("\n\n", $instructions), $input];
|
|
}
|
|
|
|
function raijuAgentResponsesBody(array $messages, array $tools, string $model, int $maxOutput, float $temperature): array {
|
|
[$instructions, $input] = raijuAgentResponsesInput($messages);
|
|
$body = ['model' => $model, 'input' => $input];
|
|
if ($instructions !== '') $body['instructions'] = $instructions;
|
|
$body += [
|
|
'tools' => array_values(array_map(static fn($tool) => [
|
|
'type' => 'function',
|
|
'name' => $tool['name'],
|
|
'description' => $tool['description'],
|
|
'parameters' => raijuAgentProviderSchema($tool['parameters']),
|
|
'strict' => false
|
|
], $tools)),
|
|
'tool_choice' => 'auto',
|
|
'max_output_tokens' => $maxOutput,
|
|
'temperature' => $temperature,
|
|
// Nothing is kept at the provider; the reasoning comes back encrypted and goes into the next turn.
|
|
'store' => false,
|
|
'include' => ['reasoning.encrypted_content'],
|
|
'stream' => false
|
|
];
|
|
return $body;
|
|
}
|
|
|
|
function raijuAgentParseResponsesResponse(array $response): array {
|
|
$text = '';
|
|
$raw = [];
|
|
$reasoning = [];
|
|
$sawReasoning = false;
|
|
foreach ((is_array($response['output'] ?? null) ? $response['output'] : []) as $item) {
|
|
if (!is_array($item)) continue;
|
|
$type = (string)($item['type'] ?? '');
|
|
if ($type === 'message') {
|
|
foreach ((is_array($item['content'] ?? null) ? $item['content'] : []) as $part) {
|
|
if (is_array($part) && ($part['type'] ?? '') === 'output_text') $text .= (string)($part['text'] ?? '');
|
|
}
|
|
} elseif ($type === 'function_call') {
|
|
$raw[] = ['id' => (string)($item['call_id'] ?? ''), 'name' => (string)($item['name'] ?? ''), 'arguments' => $item['arguments'] ?? ''];
|
|
} elseif ($type === 'reasoning') {
|
|
$sawReasoning = true;
|
|
$reasoning[] = $item;
|
|
}
|
|
}
|
|
$incomplete = ($response['status'] ?? '') === 'incomplete' && ($response['incomplete_details']['reason'] ?? '') === 'max_output_tokens';
|
|
return [
|
|
'text' => trim($text),
|
|
'calls' => $raw,
|
|
'stopReason' => $incomplete ? 'length' : ($raw ? 'tool_calls' : 'stop'),
|
|
'inputTokens' => isset($response['usage']['input_tokens']) ? (int)$response['usage']['input_tokens'] : null,
|
|
'outputTokens' => isset($response['usage']['output_tokens']) ? (int)$response['usage']['output_tokens'] : null,
|
|
'usage' => is_array($response['usage'] ?? null) ? $response['usage'] : null,
|
|
'reasoning' => $sawReasoning,
|
|
'reasoningItems' => raijuAgentCleanReasoningItems($reasoning)
|
|
];
|
|
}
|
|
|
|
function raijuAgentAnthropicBody(array $messages, array $tools, string $model, int $maxOutput, float $temperature): array {
|
|
$system = [];
|
|
$mapped = [];
|
|
foreach ($messages as $message) {
|
|
$role = $message['role'];
|
|
if ($role === 'system') {
|
|
$system[] = $message['content'];
|
|
continue;
|
|
}
|
|
$last = count($mapped) - 1;
|
|
$lastIsBlocks = $last >= 0 && $mapped[$last]['role'] === 'user' && is_array($mapped[$last]['content']);
|
|
if ($role === 'tool') {
|
|
$block = ['type' => 'tool_result', 'tool_use_id' => $message['toolCallId'], 'content' => $message['content']];
|
|
if (!empty($message['isError'])) $block['is_error'] = true;
|
|
// Consecutive results share one user message, as the Messages API requires.
|
|
if ($lastIsBlocks) {
|
|
$mapped[$last]['content'][] = $block;
|
|
} else {
|
|
$mapped[] = ['role' => 'user', 'content' => [$block]];
|
|
}
|
|
} elseif ($role === 'assistant') {
|
|
$blocks = [];
|
|
if ($message['content'] !== '') $blocks[] = ['type' => 'text', 'text' => $message['content']];
|
|
foreach ($message['toolCalls'] ?? [] as $call) {
|
|
$blocks[] = ['type' => 'tool_use', 'id' => $call['id'], 'name' => $call['name'], 'input' => $call['arguments'] ?: new stdClass()];
|
|
}
|
|
$mapped[] = ['role' => 'assistant', 'content' => $blocks];
|
|
} else {
|
|
if ($lastIsBlocks) {
|
|
$mapped[$last]['content'][] = ['type' => 'text', 'text' => $message['content']];
|
|
} else {
|
|
$mapped[] = ['role' => 'user', 'content' => $message['content']];
|
|
}
|
|
}
|
|
}
|
|
while ($mapped && $mapped[0]['role'] !== 'user') {
|
|
array_shift($mapped);
|
|
}
|
|
if (!$mapped) {
|
|
throw new RuntimeException('No AI messages provided.');
|
|
}
|
|
$body = [
|
|
'model' => $model,
|
|
'max_tokens' => $maxOutput,
|
|
'messages' => $mapped,
|
|
'tools' => array_values(array_map(static fn($tool) => [
|
|
'name' => $tool['name'],
|
|
'description' => $tool['description'],
|
|
'input_schema' => raijuAgentProviderSchema($tool['parameters'])
|
|
], $tools)),
|
|
'tool_choice' => ['type' => 'auto'],
|
|
'temperature' => min(1.0, max(0.0, $temperature))
|
|
];
|
|
if ($system) {
|
|
$body['system'] = implode("\n\n", $system);
|
|
}
|
|
return $body;
|
|
}
|
|
|
|
function raijuAgentParseAnthropicResponse(array $response): array {
|
|
$text = '';
|
|
$raw = [];
|
|
foreach ((is_array($response['content'] ?? null) ? $response['content'] : []) as $block) {
|
|
if (!is_array($block)) continue;
|
|
$type = (string)($block['type'] ?? '');
|
|
if ($type === 'text') {
|
|
$text .= (string)($block['text'] ?? '');
|
|
} elseif ($type === 'tool_use') {
|
|
$raw[] = ['id' => (string)($block['id'] ?? ''), 'name' => (string)($block['name'] ?? ''), 'arguments' => $block['input'] ?? []];
|
|
}
|
|
}
|
|
$stop = (string)($response['stop_reason'] ?? '');
|
|
return [
|
|
'text' => trim($text),
|
|
'calls' => $raw,
|
|
'stopReason' => $stop === 'max_tokens' ? 'length' : ($raw ? 'tool_calls' : 'stop'),
|
|
'inputTokens' => isset($response['usage']['input_tokens']) ? (int)$response['usage']['input_tokens'] : null,
|
|
'outputTokens' => isset($response['usage']['output_tokens']) ? (int)$response['usage']['output_tokens'] : null,
|
|
'usage' => is_array($response['usage'] ?? null) ? $response['usage'] : null,
|
|
'reasoning' => false
|
|
];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// One model turn
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* One model turn with tools.
|
|
*
|
|
* Options: model, feature (default backendAssistant, '' = none), maxOutputTokens,
|
|
* temperature, maxMessages, _requestId (ledger id, e.g. agent_{run}_{n}).
|
|
*
|
|
* @param array $tools [{name, description, parameters: JSON Schema object}]
|
|
* @return array{kind:string,text:string,toolCalls:array,stopReason:string,usage:array,message:array,requestId:string}
|
|
* kind is 'tool_calls' or 'final'; message is the assistant message to append to the transcript;
|
|
* every tool call carries an 'error' (null or a text to hand back to the model).
|
|
*/
|
|
function raijuAiChatWithTools(array $messages, array $tools, array $options = []): array {
|
|
if (!empty($GLOBALS['raijuAiRequestId'])) {
|
|
throw new LogicException('raijuAiChatWithTools() must be its own top-level AI request; nested calls would lose their usage.');
|
|
}
|
|
return raijuAiWithRequest(fn() => raijuAiChatWithToolsExecute($messages, $tools, $options), $options['_requestId'] ?? null);
|
|
}
|
|
|
|
function raijuAiChatWithToolsExecute(array $messages, array $tools, array $options): array {
|
|
$settings = raijuAiLoadSettings(false);
|
|
raijuAiEnsureEnabled($settings);
|
|
$feature = $options['feature'] ?? 'backendAssistant';
|
|
if ($feature !== '') {
|
|
raijuAiEnsureFeature($settings, $feature);
|
|
}
|
|
if (!raijuAiSupportsToolCalling($settings)) {
|
|
throw new RuntimeException('The configured AI provider does not support tool calling.');
|
|
}
|
|
|
|
$tools = raijuAgentNormalizeTools($tools);
|
|
if (!$tools) {
|
|
throw new InvalidArgumentException('At least one tool is required.');
|
|
}
|
|
$clean = raijuAgentCleanMessages($messages, (int)($options['maxMessages'] ?? RAIJU_AGENT_MAX_MESSAGES));
|
|
if (!$clean) {
|
|
throw new RuntimeException('No AI messages provided.');
|
|
}
|
|
|
|
$inputTokens = raijuAiEstimateTokens((string)json_encode([$clean, $tools], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
|
if ($inputTokens > (int)$settings['limits']['maxInputTokens']) {
|
|
throw new RuntimeException('AI input is too long for the configured limit.');
|
|
}
|
|
$model = trim((string)($options['model'] ?? '')) ?: (string)$settings['chatModel'];
|
|
$plan = raijuAiReasoningPlan($settings, $model, raijuAiOptionPurpose($options));
|
|
$maxOutput = raijuAiOutputBudget($settings, $options, $plan);
|
|
$reservation = raijuAiReservationCost($settings, $model, $inputTokens, $maxOutput);
|
|
raijuAiAssertWithinLimits($settings, 'text', $reservation['cents'], ['model' => $model, 'priced' => $reservation['priced']]);
|
|
|
|
$temperature = isset($options['temperature']) ? (float)$options['temperature'] : 0.3;
|
|
$anthropic = raijuAiProviderKey($settings) === 'anthropic';
|
|
|
|
$started = microtime(true);
|
|
if ($anthropic) {
|
|
[$url, $headers] = raijuAiAnthropicRequestTarget($settings);
|
|
$response = raijuAiExecuteJsonRequest($settings, $url, $headers, raijuAiApplyReasoning(raijuAgentAnthropicBody($clean, $tools, $model, $maxOutput, $temperature), $plan, 'anthropic'));
|
|
$parsed = raijuAgentParseAnthropicResponse($response);
|
|
} else {
|
|
$responsesApi = raijuAgentUsesResponsesApi($settings, $model);
|
|
if (!$responsesApi) {
|
|
try {
|
|
$response = raijuAiProviderRequest($settings, '/chat/completions', raijuAiApplyReasoning(raijuAgentOpenAiBody($clean, $tools, $model, $maxOutput, $temperature), $plan, 'chat'));
|
|
$parsed = raijuAgentParseOpenAiResponse($response);
|
|
} catch (RaijuAiProviderRejected $error) {
|
|
// "use /v1/responses": the model does not take tools on chat/completions. Nothing was generated; ask again there.
|
|
if (!in_array($error->status, [400, 404, 422], true) || !preg_match('~/v1/responses|/responses~i', $error->getMessage()) || raijuAiProviderKey($settings) !== 'openai-compatible') {
|
|
throw $error;
|
|
}
|
|
$responsesApi = true;
|
|
raijuAiStoreQuirk($settings, $model, ['api' => 'responses']);
|
|
}
|
|
}
|
|
if ($responsesApi) {
|
|
$response = raijuAiProviderRequest($settings, '/responses', raijuAiApplyReasoning(raijuAgentResponsesBody($clean, $tools, $model, $maxOutput, $temperature), $plan, 'responses'));
|
|
$parsed = raijuAgentParseResponsesResponse($response);
|
|
}
|
|
}
|
|
|
|
$calls = raijuAgentFinalizeToolCalls($parsed['calls'], $tools);
|
|
if ($parsed['text'] === '' && !$calls) {
|
|
if ($parsed['reasoning'] || $parsed['stopReason'] === 'length') {
|
|
throw new RuntimeException('AI provider did not return a final answer before the output limit. Increase the max output tokens or use a non-reasoning model for this feature.');
|
|
}
|
|
throw new RuntimeException('AI provider returned an empty response.');
|
|
}
|
|
|
|
$usage = raijuAiSettleText($settings, $model, $parsed['usage'] ?? null, $inputTokens, $parsed['text'] . json_encode(array_column($calls, 'arguments')));
|
|
raijuAiAudit('agent-turn', true, raijuAiAuditUsage($model, $usage, $plan) + [
|
|
'provider' => raijuAiProviderKey($settings),
|
|
'stopReason' => $parsed['stopReason'],
|
|
'tools' => array_column($calls, 'name'),
|
|
'durationMs' => (int)round((microtime(true) - $started) * 1000)
|
|
]);
|
|
|
|
$message = ['role' => 'assistant', 'content' => $parsed['text']];
|
|
if ($calls) {
|
|
$message['toolCalls'] = array_map(static fn($call) => ['id' => $call['id'], 'name' => $call['name'], 'arguments' => $call['arguments']], $calls);
|
|
if (!empty($parsed['reasoningItems'])) $message['reasoningItems'] = $parsed['reasoningItems'];
|
|
}
|
|
return [
|
|
'kind' => $calls ? 'tool_calls' : 'final',
|
|
'text' => $parsed['text'],
|
|
'toolCalls' => $calls,
|
|
'stopReason' => $parsed['stopReason'],
|
|
'usage' => raijuAiPublicUsage($usage),
|
|
'message' => $message
|
|
];
|
|
}
|