196 lines
13 KiB
PHP
196 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service\followupaudio;
|
|
|
|
/** Server-only provider identity. Changing any request identity invalidates the audio gate. */
|
|
final class FollowupAudioProviderConfig
|
|
{
|
|
public static function resolve(string $profile, ?array $settings = null, ?array $legacy = null): array
|
|
{
|
|
if (!in_array($profile, ['qwen', 'openai'], true)) { throw new FollowupAudioException('INVALID_PROFILE'); }
|
|
$settings = $settings ?? (array) config('followup_audio', []);
|
|
$legacy = $legacy ?? (array) config('prescription_ai', []);
|
|
$provider = (array) ($settings['providers'][$profile] ?? []);
|
|
$driver = (string) ($provider['driver'] ?? 'dify');
|
|
$httpTransport = $settings['http_transport'] ?? 'curl';
|
|
if (!in_array($httpTransport, ['curl', 'openssl_stream'], true) || ($driver !== 'asr_then_llm' && $httpTransport !== 'curl')) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
|
if (!in_array($driver, ['dify', 'openai_audio', 'asr_then_llm'], true)) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
|
if ($driver === 'asr_then_llm') { return self::pipeline($provider, $settings, $profile); }
|
|
$base = (string) ($provider['base_url'] ?? '');
|
|
$key = (string) ($provider['api_key'] ?? '');
|
|
if ($driver === 'dify') {
|
|
if ($base === '') { $base = (string) ($legacy['base_url'] ?? ''); }
|
|
if ($key === '') { $key = (string) ($legacy['models'][$profile]['api_key'] ?? ''); }
|
|
}
|
|
$model = (string) ($provider['model'] ?? '');
|
|
$label = trim((string) ($provider['label'] ?? ''));
|
|
if ($label === '') { $label = $model !== '' ? $model : $profile; }
|
|
if ($base === '' || $key === '' || ($driver === 'openai_audio' && $model === '')) { throw new FollowupAudioException('CONFIG_MISSING'); }
|
|
$parts = parse_url($base);
|
|
if (!is_array($parts) || !in_array($parts['scheme'] ?? '', ['http', 'https'], true)
|
|
|| empty($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment'])
|
|
|| preg_match('/[\x00-\x20\x7f\\\\]/', $base) || preg_match('/[\x00-\x20\x7f]/', $key)
|
|
|| strlen($model) > 200 || preg_match('/[\x00-\x20\x7f]/', $model)
|
|
|| trim($label) === '' || strlen($label) > 200 || preg_match('/[\x00-\x1f\x7f]/', $label)) {
|
|
throw new FollowupAudioException('CONFIG_INVALID');
|
|
}
|
|
$base = rtrim($base, '/');
|
|
$path = rtrim((string) ($parts['path'] ?? ''), '/');
|
|
if ($driver === 'dify' && str_ends_with($path, '/chat/completions')) {
|
|
throw new FollowupAudioException('DIFY_APPLICATION_REQUIRED');
|
|
}
|
|
if ($driver === 'openai_audio' && str_ends_with($path, '/chat-messages')) {
|
|
throw new FollowupAudioException('CONFIG_INVALID');
|
|
}
|
|
$endpoint = $driver === 'dify' ? '/chat-messages' : '/chat/completions';
|
|
if (str_ends_with($base, $endpoint)) { $base = substr($base, 0, -strlen($endpoint)); }
|
|
elseif (!str_ends_with($base, '/v1')) { $base .= '/v1'; }
|
|
$fingerprint = hash('sha256', json_encode([$driver, $base, $model, $key], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
|
return ['driver' => $driver, 'base_url' => $base, 'api_key' => $key, 'model' => $model, 'label' => $label, 'fingerprint' => $fingerprint];
|
|
}
|
|
|
|
/** No endpoint, model credential or plaintext provider data in capability responses. */
|
|
public static function status(string $profile, ?array $settings = null, ?array $legacy = null): array
|
|
{
|
|
try {
|
|
$provider = self::resolve($profile, $settings, $legacy);
|
|
return ['configured' => true, 'profile' => $profile, 'code' => 'OK', 'application_fingerprint' => $provider['fingerprint']];
|
|
} catch (FollowupAudioException $error) {
|
|
return ['configured' => false, 'profile' => in_array($profile, ['qwen', 'openai'], true) ? $profile : 'invalid', 'code' => $error->errorCode];
|
|
}
|
|
}
|
|
|
|
public static function isVerified(string $profile): bool
|
|
{
|
|
return self::verified($profile, (array) config('followup_audio', []), (array) config('prescription_ai', []));
|
|
}
|
|
|
|
/** Shared with injected-config transport tests; enabled remains an independent operational switch. */
|
|
public static function verified(string $profile, array $settings, array $legacy): bool
|
|
{
|
|
if (empty($settings['audio_verified']) || !in_array($profile, (array) ($settings['verified_profiles'] ?? []), true)) { return false; }
|
|
try { $provider = self::resolve($profile, $settings, $legacy); }
|
|
catch (FollowupAudioException $error) { return false; }
|
|
$verified = (string) ($settings['providers'][$profile]['verified_fingerprint'] ?? '');
|
|
$secure = $provider['driver'] === 'asr_then_llm'
|
|
? self::secureEndpoint($provider['asr']['base_url'], $provider['allow_loopback_tunnel'])
|
|
&& self::secureEndpoint($provider['extraction']['base_url'], $provider['allow_loopback_tunnel'])
|
|
: str_starts_with($provider['base_url'], 'https://');
|
|
return $secure && preg_match('/^[a-f0-9]{64}$/D', $verified)
|
|
&& hash_equals($provider['fingerprint'], $verified);
|
|
}
|
|
|
|
/** Synthetic connection readiness is not the full audio/accuracy acceptance gate. */
|
|
public static function previewVerified(string $profile, ?array $settings = null, ?array $legacy = null): bool
|
|
{
|
|
$settings = $settings ?? (array) config('followup_audio', []);
|
|
$legacy = $legacy ?? (array) config('prescription_ai', []);
|
|
try { $provider = self::resolve($profile, $settings, $legacy); }
|
|
catch (FollowupAudioException $error) { return false; }
|
|
$fingerprint = (string) ($settings['providers'][$profile]['preview_verified_fingerprint'] ?? '');
|
|
$secure = $provider['driver'] === 'asr_then_llm'
|
|
? self::secureEndpoint($provider['asr']['base_url'], $provider['allow_loopback_tunnel']) && self::secureEndpoint($provider['extraction']['base_url'], $provider['allow_loopback_tunnel'])
|
|
: str_starts_with($provider['base_url'], 'https://');
|
|
return $secure && preg_match('/^[a-f0-9]{64}$/D', $fingerprint) && hash_equals($provider['fingerprint'], $fingerprint);
|
|
}
|
|
|
|
public static function readyModels(): array
|
|
{
|
|
$models = [];
|
|
$profiles = ['qwen', 'openai'];
|
|
$preferred = config('followup_audio.profile', 'qwen');
|
|
// Preference is presentation only: existing tasks retain their bound provider identity.
|
|
if (in_array($preferred, $profiles, true)) { $profiles = array_values(array_unique(array_merge([$preferred], $profiles))); }
|
|
foreach ($profiles as $profile) {
|
|
if (FollowupAudioGate::ready($profile)) { $models[] = ['value' => $profile, 'label' => self::resolve($profile)['label']]; }
|
|
}
|
|
return $models;
|
|
}
|
|
|
|
/** Literal loopback only, explicitly configured for a locally established SSH tunnel. No DNS exception. */
|
|
public static function secureEndpoint(string $base, bool $allowLoopback): bool
|
|
{
|
|
$parts = parse_url($base);
|
|
return ($parts['scheme'] ?? '') === 'https' || ($allowLoopback && ($parts['scheme'] ?? '') === 'http'
|
|
&& in_array($parts['host'] ?? '', ['127.0.0.1', '[::1]'], true));
|
|
}
|
|
|
|
private static function pipeline(array $provider, array $settings, string $profile): array
|
|
{
|
|
$allowLoopback = ($provider['allow_loopback_tunnel'] ?? false) === true;
|
|
$resolved = ['driver' => 'asr_then_llm', 'allow_loopback_tunnel' => $allowLoopback];
|
|
foreach (['asr' => '/audio/transcriptions', 'extraction' => '/chat/completions'] as $stage => $endpoint) {
|
|
$input = (array) ($provider[$stage] ?? []);
|
|
$protocol = $input['protocol'] ?? 'openai';
|
|
$allowed = $stage === 'asr' ? ['openai', 'dify'] : ['openai', 'dify_chat'];
|
|
if (!in_array($protocol, $allowed, true)) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
|
$revision = $input['binding_revision'] ?? '';
|
|
if ($protocol !== 'openai' && (!is_string($revision) || !preg_match('/^[A-Za-z0-9_.:-]{1,128}$/D', $revision))) {
|
|
throw new FollowupAudioException('CONFIG_INVALID');
|
|
}
|
|
$checked = $input;
|
|
if ($protocol !== 'openai') {
|
|
$endpoint = $stage === 'asr' ? '/audio-to-text' : '/chat-messages';
|
|
$original = rtrim((string) ($input['base_url'] ?? ''), '/');
|
|
if (str_ends_with($original, $endpoint)) { $checked['base_url'] = substr($original, 0, -strlen($endpoint)); }
|
|
}
|
|
// Model is a bound expected deployment identity for Dify, not a fake request parameter.
|
|
$part = self::resolve($profile, ['providers' => [$profile => array_merge($checked, ['driver' => 'openai_audio'])]], []);
|
|
$base = $part['base_url'];
|
|
if ($stage === 'asr' && $protocol === 'openai') {
|
|
$original = rtrim((string) ($input['base_url'] ?? ''), '/');
|
|
if (str_ends_with($original, $endpoint)) { $base = substr($original, 0, -strlen($endpoint)); }
|
|
}
|
|
if (!self::secureEndpoint($base, $allowLoopback)) { throw new FollowupAudioException('HTTPS_REQUIRED'); }
|
|
$resolved[$stage] = ['base_url' => $base, 'api_key' => $part['api_key'], 'model' => $part['model']];
|
|
// Default/explicit OpenAI retains its previous exact fingerprint representation.
|
|
if ($protocol !== 'openai') { $resolved[$stage] += ['protocol' => $protocol, 'binding_revision' => $revision]; }
|
|
}
|
|
$chunkSeconds = (int) ($settings['asr_chunk_seconds'] ?? 120);
|
|
if ($chunkSeconds < 1 || $chunkSeconds > 120) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
|
$resolved['chunk_seconds'] = $chunkSeconds;
|
|
$stereoSeconds = (int) ($settings['asr_stereo_chunk_seconds'] ?? 15);
|
|
if ($stereoSeconds < 1 || $stereoSeconds > 30) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
|
$resolved['stereo_chunk_seconds'] = $stereoSeconds;
|
|
$resolved['channel_policy'] = FollowupAudioPipelineCheckpoint::CHANNEL_POLICY;
|
|
$resolved['silence_policy'] = FollowupAudioPipelineMedia::SILENCE_POLICY;
|
|
$mode = $provider['extraction']['response_format'] ?? 'json_schema';
|
|
$maxTokens = $provider['extraction']['max_tokens'] ?? 8192;
|
|
$thinking = $provider['extraction']['enable_thinking'] ?? null;
|
|
if (!in_array($mode, ['json_schema', 'json_object', 'prompt_json'], true)
|
|
|| !is_int($maxTokens) || $maxTokens < 256 || $maxTokens > 8192
|
|
|| ($thinking !== null && !is_bool($thinking))) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
|
if (($resolved['extraction']['protocol'] ?? 'openai') === 'dify_chat') {
|
|
if (($provider['extraction']['response_format'] ?? null) !== 'prompt_json' || $thinking !== null) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
|
$resolved['extraction']['response_format'] = 'prompt_json';
|
|
// Dify App owns model generation parameters. Local max_tokens is not transmitted or claimed effective.
|
|
} else { $resolved['extraction'] += ['response_format' => $mode, 'max_tokens' => $maxTokens, 'enable_thinking' => $thinking]; }
|
|
$resolved['output_schema'] = FollowupAudioExtractionSchema::VERSION;
|
|
$resolved['citation_policy'] = FollowupAudioTranscriptPrompt::CITATION_POLICY;
|
|
$resolved['schema_dialect'] = FollowupAudioExtractionSchema::DIALECT;
|
|
$resolved['label'] = trim((string) ($provider['label'] ?? $profile));
|
|
if ($resolved['label'] === '' || strlen($resolved['label']) > 200 || preg_match('/[\x00-\x1f\x7f]/', $resolved['label'])) {
|
|
throw new FollowupAudioException('CONFIG_INVALID');
|
|
}
|
|
$identity = [$resolved['driver'], $resolved['asr'], $resolved['extraction'],
|
|
$allowLoopback, $chunkSeconds, 'pcm-s16le-mono-16000-v1', $resolved['output_schema'], $resolved['citation_policy'], $resolved['schema_dialect'], $resolved['channel_policy'], $stereoSeconds, $resolved['silence_policy'], 'checkpoint-v2'];
|
|
if (($settings['http_transport'] ?? 'curl') !== 'curl') {
|
|
$resolved['http_transport'] = 'openssl_stream';
|
|
$identity[] = 'openssl-stream-child-v1';
|
|
}
|
|
$resolved['fingerprint'] = hash('sha256', json_encode($identity, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
|
return $resolved;
|
|
}
|
|
|
|
public static function publicModels(): array
|
|
{
|
|
$models = [];
|
|
foreach (['qwen', 'openai'] as $profile) {
|
|
if (self::isVerified($profile)) { $models[] = ['value' => $profile, 'label' => self::resolve($profile)['label']]; }
|
|
}
|
|
return $models;
|
|
}
|
|
}
|