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); } /** 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] ?? []); // Use existing endpoint/key/model syntax validation without inheriting any legacy service. $part = self::resolve($profile, ['providers' => [$profile => array_merge($input, ['driver' => 'openai_audio'])]], []); $base = $part['base_url']; if ($stage === 'asr') { $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']]; } $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'); } $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'); } $resolved['fingerprint'] = hash('sha256', json_encode([$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'], 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; } }