Friday, July 17, 2026

CHAT CONTROL: HOW EUROPE QUIETLY PASSED THE SURVEILLANCE LAW NOBODY VOTED FOR




A Critical Investigation into the EU's Most Controversial Digital Legislation, the Procedural Tricks That Made It Law, and What Every European Should Know About It

By a concerned observer of digital rights and democratic process

INTRODUCTION: THE ENVELOPE THAT WAS NEVER SEALED

Imagine writing a letter to your doctor about a sensitive health condition. You seal the envelope, hand it to the postman, and trust that nobody reads it on the way. Now imagine that before you sealed it, a government-mandated machine inside your home photographed every page, ran the images through a database, and flagged your letter for a human reviewer if anything looked suspicious. You still sealed the envelope. The postman still delivered it. But the letter was never really private.

This is not a dystopian thought experiment. This is, in functional terms, what the European Union's Chat Control legislation enables. And in the summer of 2026, it became law in a manner so procedurally peculiar that even many of the MEPs who voted against it were left shaking their heads.

The story of Chat Control is not simply a story about technology or child protection. It is a story about how democratic institutions can be engineered to produce outcomes that a majority of their members explicitly oppose. It is a story about the collision between the noble goal of protecting children and the catastrophic collateral damage of surveilling an entire continent. And it is a story that every European citizen deserves to understand in full, because the consequences will shape digital life in Europe for years to come.

CHAPTER  ONE: WHAT IS CHAT CONTROL, AND WHERE DID IT COME FROM?

The term "Chat Control" is a popular shorthand for a series of EU legislative measures officially framed around combating child sexual abuse material, abbreviated as CSAM, online. The stated goal is unimpeachable: nobody wants children to be exploited, and nobody disputes that online platforms have been used to distribute horrific material depicting child abuse. The question, and it is a profound one, is whether the methods chosen to fight this evil are proportionate, effective, and compatible with the fundamental rights of hundreds of millions of innocent people.

The legislative journey began in 2021 with what is now called Chat Control 1.0, a temporary regulation that gave technology companies a legal exemption from EU privacy rules, specifically from the ePrivacy Directive, so that they could voluntarily scan private messages for known CSAM. The word "voluntarily" is important here. Under this first iteration, companies like Meta, Google, and Microsoft could choose to scan their platforms. They were not compelled to do so, and crucially, the regulation applied only to unencrypted communications. Services using end-to-end encryption, like WhatsApp or Signal, were not covered.

This temporary measure was always intended to be a placeholder while a permanent, more comprehensive regulation was drafted. That permanent regulation, which critics quickly dubbed Chat Control 2.0, would go much further. It proposed mandatory detection orders that could compel any communication service to scan all messages, images, videos, and files, including those protected by end-to-end encryption, for CSAM and for so-called grooming behavior, meaning text conversations in which an adult attempts to manipulate a child. The mechanism proposed to achieve this without technically breaking encryption was called client-side scanning, a technology we will examine in considerable depth shortly.

Chat Control 1.0 ran from 2021 until April 3, 2026, when its legal basis expired. The drama of what happened next is the central subject of this article.

CHAPTER TWO: THE VOTE THAT WASN'T WHAT IT SEEMED

To understand the procedural sleight of hand at the heart of this story, you need to understand a few basic facts about how the European Union passes laws. The EU's primary legislative mechanism is called the Ordinary Legislative Procedure. Under this procedure, a proposed law goes through up to three readings, alternating between the European Parliament and the Council of the EU, which represents member state governments.

In a first reading, the European Parliament can approve, reject, or amend a proposal by a simple majority, meaning more than half of the votes actually cast. This is the intuitive kind of majority most people think of when they hear the word.

In a second reading, the rules change dramatically. If the Council has adopted a position and sent it back to Parliament, the Parliament can only reject or amend that position by an absolute majority, meaning 361 out of 720 MEPs must vote in favor of rejection, regardless of how many MEPs actually show up and cast votes. This is a fundamentally different threshold, and it has enormous practical consequences.

Here is a simple illustration of why this matters:

Scenario A, First Reading: 607 MEPs vote. 314 vote against, 276 vote in favor, 17 abstain. Result: the measure is rejected, because 314 is more than half of 607.

Scenario B, Second Reading: 607 MEPs vote. 314 vote against, 276 vote in favor, 17 abstain. Result: the measure passes, because 314 is less than 361, and you needed 361 to reject it.

The numbers in Scenario B are not hypothetical. They are the exact numbers from the European Parliament vote on July 9, 2026. More MEPs voted against Chat Control 1.0 than voted for it. The measure passed anyway.

This outcome did not happen by accident. It was the product of a carefully orchestrated sequence of events that began months earlier.

In March 2026, the Parliament had already dealt with this file. On March 26, 2026, MEPs voted 311 to 228, with 92 abstentions, to reject the European Commission's proposal to extend Chat Control 1.0. Digital rights advocates celebrated. The temporary framework expired on schedule on April 3, 2026. It looked, for a moment, as though the democratic process had worked.

Then the Council of the EU, representing member state governments, moved. On July 2, 2026, the Council adopted its own position on the regulation, essentially reviving the Commission's original, broader proposal. This act of the Council triggered a second reading in the European Parliament, resetting the procedural clock and, crucially, raising the bar for rejection from a simple majority to an absolute majority.

Five days later, on July 7, 2026, the European People's Party, the largest political group in the Parliament, invoked Rule 170 of the Parliament's rules of procedure, which allows for an urgency procedure. This mechanism fast-tracked the second reading directly to a plenary vote, bypassing the normal committee review process that would have allowed for detailed scrutiny and debate. The urgency procedure itself was approved by a vote of 331 to 304, a slim margin, and one that critics noted was itself a manipulation of the calendar, since the vote was scheduled just before the Parliament's summer recess, when many MEPs had already made travel arrangements and were not present in Strasbourg.

Two days after that, on July 9, 2026, the plenary vote took place. The result, as described above, was that more MEPs opposed the measure than supported it, yet it became law because the opposition fell short of the 361-vote absolute majority required to block it at second reading.

Patrick Breyer, a German MEP and one of the most vocal critics of Chat Control, described the maneuver as unprecedented. Digital rights organizations across Europe were furious. The Electronic Frontier Foundation called it an attack on fundamental human rights. European Digital Rights, known as EDRi, stated bluntly that the process constituted textbook mass surveillance passed through a procedural trick that subverted the will of the Parliament's majority.

To be precise about what passed: Chat Control 1.0, the temporary, voluntary version, was extended until April 3, 2028, or until a permanent regulation is agreed upon, whichever comes first. Two amendments explicitly excluding end-to-end encrypted services from the scanning provisions were also adopted, passing with 369 and 362 votes respectively. Critics noted, with some bitterness, that these amendments were largely symbolic, since providers of genuinely end-to-end encrypted services do not scan message content anyway. The exemption for encryption did not change the fundamental architecture of the law. It simply confirmed the status quo for encrypted services while leaving the door wide open for Chat Control 2.0 to mandate scanning of those same services through a different legal instrument.

CHAPTER THREE: THE TECHNOLOGY BEHIND THE SURVEILLANCE

To appreciate why so many technologists, cryptographers, and civil liberties experts are alarmed by Chat Control, you need to understand what the technology actually does and what it cannot do.

There are two main technical approaches to detecting CSAM in digital communications. The first is called hash matching or perceptual hashing. The second is AI-based classification.

Hash matching works by converting an image or video into a unique digital fingerprint, called a hash, and then comparing that fingerprint against a database of known illegal material. The most widely used tool for this is Microsoft's PhotoDNA. If your image produces a hash that matches a hash in the database, the system flags it. This approach is reasonably reliable for detecting previously identified material, because the same image will always produce the same hash. However, it is completely blind to new material that has never been reported before, and it can be defeated trivially by making minor alterations to an image, such as cropping a few pixels or adjusting the brightness, which will produce a completely different hash.

AI-based classification is the approach proposed for detecting new, previously unknown CSAM and for detecting grooming in text conversations. This is where the accuracy problems become truly alarming.

Consider the following illustration of the mathematics of false positives, which is perhaps the most important and least understood aspect of this entire debate.

Suppose a detection system is 99.9 percent accurate. That sounds excellent. Now suppose it is applied to one billion messages per day, which is a conservative estimate for the volume of messages sent across EU platforms. A 0.1 percent error rate means one million false positives per day. One million innocent people having their private messages flagged, reviewed, and potentially reported to law enforcement every single day.

The EU's own evaluation of Chat Control algorithms found nearly 48 percent false positives in testing. Yubo, a social platform, reported error rates of 20 percent in 2023 and 13 percent in 2024 for its grooming detection system. German police and former EU Commissioner Ylva Johansson have both acknowledged actual error rates for AI detection in the range of 50 to 75 percent. One analysis of the proposed Chat Control 2.0 system concluded that if a user is flagged, there is only a 0.18 percent chance they are actually an offender. At EU scale, this could translate to approximately 22 million false positive investigations per year.

Even the EU Commission's own implementation report for Chat Control 1.0 acknowledged that 75 percent of flagged chats were not actionable. Three out of every four flags led nowhere. They were innocent people whose communications had been examined by a machine and, in many cases, by a human reviewer, for no reason other than statistical noise.

Now consider what it means in human terms to be a false positive. Your private message, perhaps a photograph of your child at bathtime, perhaps a medical image shared with a family member, perhaps an intimate photograph shared with a partner, is flagged. It is reviewed by a human moderator employed by a private company. It may be reported to law enforcement. You may receive a visit from police. You may face an investigation. Even if you are cleared, the experience is traumatic, the reputational damage can be lasting, and the chilling effect on your future behavior is real and measurable.

Now let us look at what client-side scanning actually means in practice, because this is the technology at the heart of Chat Control 2.0 and the one that has provoked the most intense opposition from the cryptographic and security communities.

In a standard end-to-end encrypted messaging system, a message is encrypted on your device before it leaves, transmitted as unreadable ciphertext, and decrypted only on the recipient's device. No one in between, not the service provider, not the government, not a hacker intercepting the transmission, can read it. This is the fundamental security guarantee of end-to-end encryption, and it is the reason that journalists, lawyers, doctors, activists, abuse survivors, and ordinary people rely on it every day.

Client-side scanning inserts a step before the encryption. Before your message is encrypted and sent, software running on your device analyzes the content, compares it against a database or runs it through an AI classifier, and decides whether to flag it. If it is flagged, a report is generated and sent to a third party. The message is then encrypted and sent as normal.

The proponents of client-side scanning argue that this does not break encryption, because the encryption itself is not touched. The message is still encrypted in transit. But this argument is, as one expert put it, like saying that a guard who reads your letter before you seal the envelope has not violated your privacy because the envelope was sealed when it arrived. The privacy guarantee of end-to-end encryption is not merely about the security of the transmission. It is about the assurance that no third party has access to the content of your communication at any point. Client-side scanning destroys that assurance entirely.

The security implications extend beyond privacy. Implementing client-side scanning requires adding new code to every device running the messaging application. That code introduces new attack surfaces. A malicious actor who can manipulate the hash database or the AI classifier can, in principle, cause the system to flag innocent content or to ignore actual illegal material. A government with authoritarian tendencies can, in principle, update the database to include political speech, religious content, or journalism that it wishes to suppress. The infrastructure for mass surveillance, once built, does not limit itself to its original purpose.

This last point is not speculation. It is historical pattern. The legal frameworks and technical infrastructures built for one purpose have a documented tendency to expand. The USA PATRIOT Act, passed in 2001 to combat terrorism, was used for years to justify mass surveillance of ordinary communications that had nothing to do with terrorism, as the Snowden revelations of 2013 made clear. The question is not whether a surveillance infrastructure can be abused. It is whether it will be.

CHAPTER FOUR: THE DISADVANTAGES FOR EUROPEAN CITIZENS, IN FULL

The disadvantages of Chat Control for ordinary European citizens are numerous, interconnected, and in some cases irreversible. They deserve to be examined one by one, in full, without the kind of dismissive brevity that often characterizes official reassurances.

The first and most fundamental disadvantage is the destruction of the presumption of innocence. Every European citizen who uses a messaging platform covered by Chat Control has their communications scanned without any suspicion of wrongdoing, without any judicial warrant, and without any individual decision by a competent authority that their communications are worth examining. This is the definition of mass surveillance. It treats every person as a potential criminal and subjects their private life to automated scrutiny as the default condition of using digital communication. The European Court of Justice has stated explicitly that indiscriminate mass surveillance of communication content violates the essence of the right to privacy under the EU Charter of Fundamental Rights. Chat Control does exactly what the Court said cannot be done.

The second disadvantage is the chilling effect on free expression. When people know or suspect that their communications are being monitored, they change their behavior. They self-censor. They avoid discussing sensitive topics, even entirely legal ones. Abuse survivors may be reluctant to share their experiences digitally. Journalists may hesitate to communicate with sources. Lawyers may be unable to guarantee the confidentiality of client communications. LGBTQ+ individuals in countries where social attitudes remain hostile may be afraid to communicate openly about their identities. The chilling effect is not hypothetical. It is a well-documented psychological and sociological phenomenon, and it is one of the most corrosive consequences of surveillance, because it damages the fabric of free society without leaving visible fingerprints.

The third disadvantage is the risk of wrongful investigation and prosecution. As the false positive statistics above make clear, the detection systems proposed under Chat Control are not reliable enough to be used as the basis for law enforcement action. Yet that is precisely what they are being used for. An innocent parent who photographs their child in the bath, an innocent couple who share intimate photographs, an innocent doctor who sends a medical image to a colleague, all of these people face the real possibility of being flagged, reported, and investigated. The psychological, professional, and social consequences of a false accusation of child abuse are devastating and often permanent, even when the accusation is ultimately disproven.

The fourth disadvantage is the undermining of cybersecurity for everyone. End-to-end encryption is not merely a privacy tool. It is a foundational security technology that protects banking transactions, medical records, business communications, government communications, and critical infrastructure. Weakening encryption, whether by mandating client-side scanning or by any other means, weakens the security of every system that depends on it. This is not a trade-off between privacy and security. It is a trade-off between privacy and a different kind of security risk, one that is diffuse, systemic, and potentially catastrophic.

The fifth disadvantage is scope creep. The infrastructure built to scan for CSAM is technically capable of scanning for anything. The database of hashes or the AI classifier can be updated to flag any content that a government or a company decides it wants to suppress. This is not a theoretical risk. It is an architectural certainty. Once the scanning infrastructure is in place, the only barrier to its expansion is political will, and political will is a fragile thing. Today it is child protection. Tomorrow it could be political dissent, religious expression, or journalism that embarrasses the powerful.

The sixth disadvantage is the impact on non-EU citizens. The Chat Control regulation applies to any communication service that serves users in the EU. This means that a person in the United States communicating with a friend in Germany, or a journalist in Turkey communicating with a source in France, may have their communications scanned under EU law. The extraterritorial reach of the regulation is rarely discussed in the public debate, but it is real and significant.

The seventh disadvantage is the burden on small and medium-sized technology companies. Large platforms like Meta and Google have the resources to implement scanning systems, however reluctantly. Smaller companies, startups, and open-source projects do not. The compliance costs of Chat Control create a structural advantage for large incumbents and a structural barrier for smaller competitors and innovators. This is particularly damaging for the European technology sector, which has been trying for years to develop alternatives to American and Chinese digital giants.

The eighth disadvantage is the impact on vulnerable communities. People who depend most on secure, private communication are often those who are most at risk from surveillance: domestic abuse survivors communicating with support organizations, whistleblowers communicating with journalists, political dissidents communicating with human rights organizations, and LGBTQ+ individuals in hostile environments. Chat Control disproportionately harms the people it is least equipped to protect.

CHAPTER FIVE: THE CHILD PROTECTION ARGUMENT AND ITS LIMITS

It would be dishonest and unfair to dismiss the child protection argument entirely. Child sexual abuse is a real and horrifying crime. The material that depicts it causes ongoing harm to its victims every time it is viewed or shared. Law enforcement agencies across Europe and the world have legitimate needs for tools to detect and prosecute those who create and distribute it. Nobody who opposes Chat Control is indifferent to the suffering of children.

The problem is that Chat Control, as designed, is not an effective tool for child protection. It is a blunt instrument that causes enormous collateral damage while failing to achieve its stated goal with any reliability.

Consider the following. The EU Commission's own implementation report for Chat Control 1.0 found that 75 percent of flagged communications were not actionable. That means that for every four reports generated by the system, three were useless. Law enforcement agencies, already stretched thin, were flooded with irrelevant data. The signal was buried in noise. This is not a system that helps investigators find and prosecute child abusers. It is a system that overwhelms investigators with false alarms while sophisticated offenders, who are well aware of the limitations of hash-matching technology, simply alter their material slightly or move to platforms and networks that are not covered by the regulation.

The most determined and organized perpetrators of child sexual abuse do not use mainstream messaging platforms. They use encrypted peer-to-peer networks, darknet forums, and purpose-built tools designed to evade detection. Chat Control does nothing to address these channels. It scans the communications of ordinary people on mainstream platforms while leaving the actual criminal networks largely untouched.

This is a pattern that security researchers call security theater: measures that create the appearance of security without providing its substance, while imposing real costs on innocent people. The costs of Chat Control are borne by the hundreds of millions of Europeans whose private communications are scanned. The benefits, such as they are, accrue to a system that is already generating more false positives than true positives and that sophisticated criminals have every incentive to circumvent.

The alternative approach, advocated by the European Parliament's own majority and by digital rights organizations, is targeted detection. Instead of scanning everyone's communications all the time, law enforcement agencies would obtain judicial warrants to surveil specific individuals who are already under suspicion. This is how law enforcement has always worked in the physical world. Police do not search every house in a city to find a burglar. They obtain evidence, identify suspects, and seek judicial authorization to search specific premises. There is no principled reason why digital communications should be treated differently, and there are very good reasons, rooted in fundamental rights and democratic values, why they should not be.

CHAPTER SIX: WHAT COMES NEXT, AND WHY IT MATTERS

Chat Control 1.0, as extended in July 2026, is a temporary measure. It runs until April 2028, or until a permanent regulation is agreed upon. The permanent regulation, Chat Control 2.0, is where the real battle lies.

Chat Control 2.0 would go far beyond the voluntary scanning of unencrypted communications that characterizes the current law. It would mandate detection orders compelling any communication service to scan all messages, including those protected by end-to-end encryption. It would require the scanning of text messages for grooming behavior using AI classifiers. It would extend to cloud storage and email. And it would apply to all services that serve EU users, regardless of where those services are based.

Negotiations for Chat Control 2.0 were ongoing as of mid-2026, with crucial talks expected to resume in September 2026 and a possible adoption date of October 2026. The European Parliament has consistently pushed for a paradigm shift, demanding that any detection orders be targeted at specific criminal suspects rather than applied indiscriminately to all users. Member state governments, led by a coalition that includes several countries with a poor track record on digital rights, have insisted on maintaining the mass-scanning approach.

The outcome of these negotiations will determine whether Europe becomes the first major democratic jurisdiction to mandate mass surveillance of private communications as a permanent feature of digital life. The stakes could not be higher.

For ordinary Europeans, the practical consequences of Chat Control 2.0 would be profound. Every message you send, every photograph you share, every email you write, would be subject to automated analysis before it leaves your device. The results of that analysis would be held by private companies, shared with government agencies, and potentially used as the basis for law enforcement action. The privacy of digital communication, which most people take for granted, would become a legal fiction.

For the European technology sector, the consequences would be equally severe. Companies that offer end-to-end encrypted services would face an impossible choice: comply with detection orders and destroy the security guarantees that make their products valuable, or refuse to comply and face legal consequences. Signal, the encrypted messaging application, has already stated publicly that it would rather leave the EU market than implement client-side scanning. Threema, a Swiss-based encrypted messaging service, has made similar statements. If the most privacy-respecting communication tools are driven out of the EU market, the only options left for European users will be platforms that are already scanning their communications, which is precisely the outcome that Chat Control's proponents seem to desire.

For the global internet, the consequences of the EU setting a precedent for mandatory mass scanning of private communications are difficult to overstate. The EU's regulatory decisions have historically had a global impact, a phenomenon sometimes called the Brussels Effect, because companies operating globally find it easier to apply EU standards everywhere than to maintain separate systems for different jurisdictions. If the EU mandates client-side scanning, there is a real risk that this technology will spread to other jurisdictions, including those with far less robust rule-of-law protections than Europe currently enjoys.

CHAPTER SEVEN: THE DEMOCRATIC DEFICIT AND WHAT IT REVEALS

The procedural story of Chat Control 1.0's passage in July 2026 is worth dwelling on, not merely as a curiosity, but as a symptom of a deeper problem in EU governance.

The European Parliament is the only directly elected institution in the EU system. It is the body that is supposed to represent the democratic will of European citizens. When a majority of that body votes against a measure, the expectation of any reasonable person is that the measure will not become law. That expectation was confounded in July 2026, and the mechanism by which it was confounded, the absolute majority requirement at second reading combined with the urgency procedure that prevented full deliberation, was not an accident. It was a feature of the system, deployed deliberately by those who wanted a specific outcome.

The urgency procedure, Rule 170, was invoked by the European People's Party specifically to prevent the file from going to committee, where it would have received detailed scrutiny, where amendments could have been debated, and where the Parliament's opposition to the measure could have been organized and articulated. By fast-tracking the vote to a plenary session just before the summer recess, the EPP ensured that many MEPs who might have voted against the measure were not present, that the debate was compressed, and that the procedural threshold for rejection, which required an absolute majority, was much harder to meet.

This is not illegal. It is not even unusual in the rough-and-tumble of legislative politics. But it is a textbook example of how procedural rules can be weaponized to produce outcomes that are contrary to the expressed preferences of a majority of legislators. And when the subject matter is a law that affects the fundamental rights of 450 million people, the use of such tactics is not merely a procedural curiosity. It is a democratic scandal.

The broader lesson is that the EU's legislative process, for all its complexity and its genuine commitment to democratic values, contains structural vulnerabilities that can be exploited by determined actors who know the rules well enough to use them against the spirit of democratic representation. The Chat Control story is a case study in how this exploitation works in practice, and it should prompt serious reflection about whether the procedural rules of the Ordinary Legislative Procedure are adequate safeguards for fundamental rights in the digital age.

CHAPTER EIGHT: VOICES FROM THE OPPOSITION

The opposition to Chat Control has been broad, deep, and remarkably consistent across ideological lines. It has united libertarians and progressives, technologists and lawyers, privacy advocates and law enforcement professionals who understand that mass surveillance does not make investigations more effective. It has been endorsed by the United Nations, the European Court of Human Rights, the European Data Protection Supervisor, and dozens of civil society organizations.

The European Data Protection Supervisor, Wojciech Wiewiorowski, stated that the proposed regulation would constitute an unprecedented intrusion into the privacy of communications and would be incompatible with EU fundamental rights law. The UN Special Rapporteur on the right to privacy described client-side scanning as personalized spyware deployed on millions of devices. The European Court of Human Rights has ruled, in the context of Russian surveillance law, that indiscriminate mass interception of communications violates the European Convention on Human Rights, a ruling that has direct implications for Chat Control.

Within the European Parliament, the opposition has been led by MEPs from across the political spectrum, united by the conviction that the protection of children cannot be used as a justification for the destruction of privacy for everyone. Patrick Breyer of the German Pirate Party has been the most visible and persistent critic, but he has been joined by MEPs from the Greens, the Socialists, the Liberals, and even some members of the European People's Party who broke with their group's leadership on this issue.

The technology community has been equally vocal. More than 100 cryptography and security researchers signed an open letter warning that client-side scanning is technically incompatible with the security guarantees of end-to-end encryption and that its implementation would create serious vulnerabilities that could be exploited by malicious actors. Apple, which briefly experimented with a form of client-side scanning for iCloud photos in 2021 before abandoning the project following a massive backlash, has stated that it will not implement such systems. Signal has threatened to leave the EU market. The message from the technical community has been consistent and unambiguous: you cannot scan encrypted communications without breaking encryption, and breaking encryption makes everyone less safe.

CONCLUSION: THE PRICE OF PROTECTION

There is a version of this story that the EU Commission and the proponents of Chat Control would like you to believe. In that version, a caring and responsible government is using the best available technology to protect the most vulnerable members of society, and a few privacy absolutists are standing in the way of child protection because they care more about abstract principles than about real children.

That version of the story is false, and it is important to say so clearly.

The real story is that a well-intentioned goal, protecting children from sexual abuse, is being used to justify a surveillance architecture that is technically unreliable, legally questionable, democratically dubious, and strategically counterproductive. The children who are most at risk from online abuse are not protected by scanning the messages of hundreds of millions of innocent adults. They are protected by targeted law enforcement, by education, by support services, and by the kind of trust-based relationships with adults that surveillance destroys rather than builds.

The real story is that the procedural mechanisms of the EU's legislative process were used, deliberately and skillfully, to pass a law that a majority of the directly elected representatives of European citizens voted against. This is not democracy. It is the simulation of democracy, and the difference matters enormously.

The real story is that the infrastructure being built under the banner of child protection is an infrastructure of mass surveillance that, once in place, will be extraordinarily difficult to dismantle and extraordinarily easy to expand. The history of surveillance technology is a history of mission creep, of tools built for one purpose being repurposed for others, of temporary measures becoming permanent, of exceptions becoming rules.

Every European who uses a messaging application, sends an email, or stores photographs in the cloud has a stake in this story. The question is not whether you have something to hide. The question is whether you believe that a government, any government, should have the ability to read your private communications without suspicion, without a warrant, and without your knowledge or consent. If your answer is no, then Chat Control is a law you should know about, understand deeply, and oppose with every democratic tool available to you.

The envelope should be sealed before it leaves your hands. That is not a privilege. It is a right.

SOURCES AND FURTHER READING

The factual claims in this article are based on publicly available information including European Parliament voting records for March 26, 2026 and July 9, 2026; the European Commission's implementation report for Chat Control 1.0; research published by European Digital Rights (EDRi) at edri.org; analyses published by the Electronic Frontier Foundation at eff.org; the open letter signed by cryptography and security researchers opposing client-side scanning; statements by the European Data Protection Supervisor; rulings of the European Court of Human Rights on mass surveillance; and reporting by Patrick Breyer at patrick-breyer.de, which has served as one of the most detailed and consistently updated sources of information on the Chat Control legislative process.

INTELLIGENT LLM APPLICATION ARCHITECTURE: GPU DETECTION, MODEL MANAGEMENT, AND AUTOMATIC MODEL SELECTION


 


INTRODUCTION

Modern large language model applications face significant challenges in deployment across heterogeneous hardware environments. Organizations and individual developers need systems that can automatically detect available GPU hardware, select optimal execution backends, manage multiple model sources both local and remote, implement failover mechanisms when primary models become unavailable, and intelligently route user prompts to the most appropriate model based on task characteristics. This article presents a comprehensive architecture for building production-ready LLM applications that address all these requirements.

The core challenge lies in creating a unified abstraction layer that handles the complexity of different GPU vendors including Nvidia CUDA, AMD ROCm, Intel oneAPI, and Apple Metal Performance Shaders while providing seamless model switching capabilities. Additionally, the system must make intelligent decisions about which model to use for a given task, considering factors such as model capabilities, computational requirements, latency constraints, and availability.

GPU HARDWARE DETECTION AND SELECTION

The first critical component of an intelligent LLM application is the ability to detect and utilize available GPU hardware. Different GPU vendors provide different software stacks and APIs. Nvidia uses CUDA, AMD provides ROCm for their GPUs, Intel offers oneAPI and Level Zero, and Apple provides Metal Performance Shaders for their unified memory architecture. A robust detection system must probe for all these backends and select the optimal one based on performance characteristics.

The detection process begins by attempting to import and initialize each GPU backend library. For Nvidia GPUs, we check for CUDA availability through PyTorch or TensorFlow. For AMD, we look for ROCm support. Intel GPUs are detected through oneAPI or OpenCL interfaces. Apple Silicon detection happens through the Metal framework availability check. Each detection attempt must be wrapped in exception handling because the libraries may not be installed or the hardware may not be present.

Here is a foundational GPU detection implementation:

import sys
import subprocess
from typing import Optional, Dict, List, Tuple
from enum import Enum

class GPUBackend(Enum):
    CUDA = "cuda"
    ROCM = "rocm"
    MPS = "mps"
    INTEL = "intel"
    CPU = "cpu"

class GPUDetector:
    def __init__(self):
        self.available_backends = []
        self.backend_info = {}
        self.detect_all_backends()
    
    def detect_cuda(self) -> Tuple[bool, Dict]:
        """Detect Nvidia CUDA GPUs and gather information"""
        try:
            import torch
            if torch.cuda.is_available():
                device_count = torch.cuda.device_count()
                devices = []
                for i in range(device_count):
                    props = torch.cuda.get_device_properties(i)
                    devices.append({
                        'index': i,
                        'name': props.name,
                        'compute_capability': f"{props.major}.{props.minor}",
                        'total_memory': props.total_memory,
                        'multi_processor_count': props.multi_processor_count
                    })
                return True, {'device_count': device_count, 'devices': devices}
        except Exception as e:
            pass
        return False, {}

The CUDA detection function attempts to import PyTorch and query CUDA availability. If successful, it enumerates all available CUDA devices and collects detailed information including device name, compute capability, total memory, and multiprocessor count. This information is crucial for later performance optimization decisions. The compute capability version determines which CUDA features are available and influences model loading strategies.

For AMD ROCm detection, the process is similar but uses ROCm-specific APIs:

    def detect_rocm(self) -> Tuple[bool, Dict]:
        """Detect AMD ROCm GPUs"""
        try:
            import torch
            if hasattr(torch.version, 'hip') and torch.version.hip is not None:
                if torch.cuda.is_available():
                    device_count = torch.cuda.device_count()
                    devices = []
                    for i in range(device_count):
                        props = torch.cuda.get_device_properties(i)
                        devices.append({
                            'index': i,
                            'name': props.name,
                            'total_memory': props.total_memory,
                            'gcn_arch': props.gcnArchName if hasattr(props, 'gcnArchName') else 'unknown'
                        })
                    return True, {'device_count': device_count, 'devices': devices}
        except Exception as e:
            pass
        
        try:
            result = subprocess.run(['rocm-smi', '--showproductname'], 
                                  capture_output=True, text=True, timeout=5)
            if result.returncode == 0 and result.stdout:
                return True, {'detected_via': 'rocm-smi', 'info': result.stdout.strip()}
        except Exception as e:
            pass
        
        return False, {}

ROCm detection is more complex because PyTorch built with ROCm support uses a CUDA-compatible API but runs on AMD hardware. We first check for the HIP version in PyTorch which indicates ROCm support. Additionally, we attempt to run the rocm-smi command-line tool which provides system management information for AMD GPUs. This dual approach ensures detection even when PyTorch is not available or not built with ROCm support.

Apple Metal Performance Shaders detection for Apple Silicon:

    def detect_mps(self) -> Tuple[bool, Dict]:
        """Detect Apple Metal Performance Shaders"""
        try:
            import torch
            if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
                return True, {'backend': 'mps', 'built': torch.backends.mps.is_built()}
        except Exception as e:
            pass
        
        try:
            import platform
            if platform.system() == 'Darwin' and platform.machine() == 'arm64':
                return True, {'platform': 'Apple Silicon', 'detected_via': 'platform'}
        except Exception as e:
            pass
        
        return False, {}

Apple Silicon detection checks for MPS availability in PyTorch backends. Since MPS is only available on Apple Silicon Macs, we also verify the platform is Darwin with ARM64 architecture. This provides a fallback detection method when PyTorch is not available but the application is running on compatible hardware.

Intel GPU detection:

    def detect_intel(self) -> Tuple[bool, Dict]:
        """Detect Intel GPUs with oneAPI support"""
        try:
            import torch
            if hasattr(torch, 'xpu') and torch.xpu.is_available():
                device_count = torch.xpu.device_count()
                devices = []
                for i in range(device_count):
                    devices.append({
                        'index': i,
                        'name': torch.xpu.get_device_name(i)
                    })
                return True, {'device_count': device_count, 'devices': devices}
        except Exception as e:
            pass
        
        try:
            result = subprocess.run(['sycl-ls'], capture_output=True, text=True, timeout=5)
            if result.returncode == 0 and 'gpu' in result.stdout.lower():
                return True, {'detected_via': 'sycl-ls', 'info': result.stdout.strip()}
        except Exception as e:
            pass
        
        return False, {}

Intel GPU detection looks for Intel Extension for PyTorch XPU support. The XPU device represents Intel GPUs accessible through oneAPI. We also attempt to run sycl-ls which lists all SYCL devices including Intel GPUs. This command-line tool is part of the Intel oneAPI toolkit.

After detecting all available backends, the system must select the optimal one. The selection process considers multiple factors including raw computational power, memory bandwidth, driver stability, and framework support quality. Generally, the priority order is CUDA for Nvidia GPUs due to mature ecosystem support, followed by ROCm for AMD, MPS for Apple Silicon, Intel for Intel GPUs, and CPU as the fallback.

    def detect_all_backends(self):
        """Detect all available GPU backends and rank them"""
        cuda_available, cuda_info = self.detect_cuda()
        if cuda_available:
            self.available_backends.append(GPUBackend.CUDA)
            self.backend_info[GPUBackend.CUDA] = cuda_info
        
        rocm_available, rocm_info = self.detect_rocm()
        if rocm_available and not cuda_available:
            self.available_backends.append(GPUBackend.ROCM)
            self.backend_info[GPUBackend.ROCM] = rocm_info
        
        mps_available, mps_info = self.detect_mps()
        if mps_available:
            self.available_backends.append(GPUBackend.MPS)
            self.backend_info[GPUBackend.MPS] = mps_info
        
        intel_available, intel_info = self.detect_intel()
        if intel_available:
            self.available_backends.append(GPUBackend.INTEL)
            self.backend_info[GPUBackend.INTEL] = intel_info
        
        self.available_backends.append(GPUBackend.CPU)
        self.backend_info[GPUBackend.CPU] = {'fallback': True}
    
    def get_optimal_backend(self) -> GPUBackend:
        """Select the optimal backend based on availability and priority"""
        priority_order = [GPUBackend.CUDA, GPUBackend.ROCM, GPUBackend.MPS, 
                         GPUBackend.INTEL, GPUBackend.CPU]
        
        for backend in priority_order:
            if backend in self.available_backends:
                return backend
        
        return GPUBackend.CPU

The detection orchestration method probes all backends systematically. Note that ROCm detection only registers if CUDA is not available, preventing double-counting of AMD GPUs that might appear through both interfaces. The optimal backend selection follows the priority order, returning the first available backend from the prioritized list.

MODEL LOADING AND MANAGEMENT ARCHITECTURE

Once the optimal GPU backend is determined, the next challenge is loading and managing LLM models from various sources. Models may reside locally on disk, be downloaded from remote repositories like Hugging Face, or be accessed through API endpoints. The model management system must provide a unified interface for all these sources while handling caching, version management, and resource allocation.

The model manager maintains a registry of available models with metadata including model type, source location, size, quantization level, and hardware requirements. This registry enables intelligent model selection based on task requirements and available resources.

from dataclasses import dataclass
from typing import Optional, Union, Any
from pathlib import Path
import json
import hashlib

@dataclass
class ModelConfig:
    model_id: str
    model_type: str
    source: str
    local_path: Optional[Path]
    remote_url: Optional[str]
    api_endpoint: Optional[str]
    size_gb: float
    quantization: Optional[str]
    context_length: int
    required_memory_gb: float
    supported_backends: List[GPUBackend]
    capabilities: List[str]
    priority: int

class ModelRegistry:
    def __init__(self, registry_path: Optional[Path] = None):
        self.registry_path = registry_path or Path.home() / '.llm_app' / 'models.json'
        self.models = {}
        self.load_registry()
    
    def load_registry(self):
        """Load model registry from disk"""
        if self.registry_path.exists():
            with open(self.registry_path, 'r') as f:
                data = json.load(f)
                for model_id, config_dict in data.items():
                    config_dict['local_path'] = Path(config_dict['local_path']) if config_dict.get('local_path') else None
                    config_dict['supported_backends'] = [GPUBackend(b) for b in config_dict['supported_backends']]
                    self.models[model_id] = ModelConfig(**config_dict)
        else:
            self.initialize_default_registry()
    
    def save_registry(self):
        """Persist model registry to disk"""
        self.registry_path.parent.mkdir(parents=True, exist_ok=True)
        data = {}
        for model_id, config in self.models.items():
            config_dict = config.__dict__.copy()
            config_dict['local_path'] = str(config_dict['local_path']) if config_dict['local_path'] else None
            config_dict['supported_backends'] = [b.value for b in config_dict['supported_backends']]
            data[model_id] = config_dict
        
        with open(self.registry_path, 'w') as f:
            json.dump(data, f, indent=2)

The ModelConfig dataclass encapsulates all relevant information about a model. The source field indicates whether the model is local, remote, or API-based. The local_path points to the model files on disk if available. For remote models, remote_url specifies the download location. API-based models use api_endpoint for inference requests. The size_gb and required_memory_gb fields help the system determine if sufficient resources are available before attempting to load a model. The supported_backends list restricts which GPU types can run the model efficiently. Capabilities describe what tasks the model excels at, such as code generation, mathematical reasoning, or general conversation.

The ModelRegistry class manages the collection of available models. It persists the registry to disk as JSON, enabling the configuration to survive application restarts. The load_registry method reconstructs ModelConfig objects from the saved JSON, properly converting string paths back to Path objects and string backend names back to GPUBackend enum values.

Model loading involves several steps including verification that the model exists, checking resource availability, downloading if necessary, and initializing the model with the appropriate backend. Here is the model loader implementation:

import requests
from tqdm import tqdm
import torch

class ModelLoader:
    def __init__(self, registry: ModelRegistry, gpu_detector: GPUDetector, cache_dir: Optional[Path] = None):
        self.registry = registry
        self.gpu_detector = gpu_detector
        self.cache_dir = cache_dir or Path.home() / '.llm_app' / 'cache'
        self.cache_dir.mkdir(parents=True, exist_ok=True)
        self.loaded_models = {}
        self.current_backend = gpu_detector.get_optimal_backend()
    
    def download_model(self, model_config: ModelConfig) -> Path:
        """Download model from remote source if not cached"""
        if model_config.local_path and model_config.local_path.exists():
            return model_config.local_path
        
        if not model_config.remote_url:
            raise ValueError(f"No remote URL available for model {model_config.model_id}")
        
        model_cache_path = self.cache_dir / model_config.model_id
        model_cache_path.mkdir(parents=True, exist_ok=True)
        
        if model_config.source == 'huggingface':
            return self.download_from_huggingface(model_config, model_cache_path)
        else:
            return self.download_from_url(model_config.remote_url, model_cache_path)
    
    def download_from_huggingface(self, model_config: ModelConfig, cache_path: Path) -> Path:
        """Download model from Hugging Face Hub"""
        try:
            from huggingface_hub import snapshot_download
            
            snapshot_download(
                repo_id=model_config.remote_url,
                cache_dir=cache_path,
                local_dir=cache_path / 'model',
                local_dir_use_symlinks=False
            )
            
            final_path = cache_path / 'model'
            model_config.local_path = final_path
            self.registry.save_registry()
            return final_path
            
        except Exception as e:
            raise RuntimeError(f"Failed to download from Hugging Face: {str(e)}")
    
    def download_from_url(self, url: str, cache_path: Path) -> Path:
        """Download model from direct URL with progress tracking"""
        filename = url.split('/')[-1]
        file_path = cache_path / filename
        
        if file_path.exists():
            return file_path
        
        response = requests.get(url, stream=True)
        response.raise_for_status()
        
        total_size = int(response.headers.get('content-length', 0))
        
        with open(file_path, 'wb') as f, tqdm(
            desc=filename,
            total=total_size,
            unit='iB',
            unit_scale=True,
            unit_divisor=1024,
        ) as progress_bar:
            for chunk in response.iter_content(chunk_size=8192):
                size = f.write(chunk)
                progress_bar.update(size)
        
        return file_path

The ModelLoader class coordinates model acquisition and initialization. The download_model method first checks if the model is already available locally. If not, it determines the source type and delegates to the appropriate download handler. For Hugging Face models, we use the official huggingface_hub library which handles authentication, caching, and incremental downloads efficiently. For direct URL downloads, we implement streaming download with progress tracking using the requests library and tqdm for user feedback.

Loading a model into memory requires backend-specific initialization. Different frameworks and backends have different APIs for model loading. For transformer-based models, we typically use the transformers library from Hugging Face, but the device placement and optimization strategies vary by backend.

    def load_model(self, model_id: str, backend: Optional[GPUBackend] = None) -> Any:
        """Load model into memory with appropriate backend"""
        if model_id in self.loaded_models:
            return self.loaded_models[model_id]
        
        if model_id not in self.registry.models:
            raise ValueError(f"Model {model_id} not found in registry")
        
        model_config = self.registry.models[model_id]
        target_backend = backend or self.current_backend
        
        if target_backend not in model_config.supported_backends:
            raise ValueError(f"Model {model_id} does not support backend {target_backend.value}")
        
        backend_info = self.gpu_detector.backend_info.get(target_backend, {})
        if target_backend != GPUBackend.CPU:
            available_memory = self.get_available_memory(target_backend, backend_info)
            if available_memory < model_config.required_memory_gb:
                raise RuntimeError(f"Insufficient memory: need {model_config.required_memory_gb}GB, have {available_memory}GB")
        
        model_path = self.download_model(model_config)
        
        if model_config.model_type == 'transformers':
            model = self.load_transformers_model(model_config, model_path, target_backend)
        elif model_config.model_type == 'llama_cpp':
            model = self.load_llama_cpp_model(model_config, model_path, target_backend)
        elif model_config.model_type == 'api':
            model = self.create_api_client(model_config)
        else:
            raise ValueError(f"Unsupported model type: {model_config.model_type}")
        
        self.loaded_models[model_id] = model
        return model
    
    def get_available_memory(self, backend: GPUBackend, backend_info: Dict) -> float:
        """Get available GPU memory in GB"""
        if backend == GPUBackend.CUDA:
            import torch
            if torch.cuda.is_available():
                device = torch.cuda.current_device()
                total = torch.cuda.get_device_properties(device).total_memory
                allocated = torch.cuda.memory_allocated(device)
                return (total - allocated) / (1024 ** 3)
        elif backend == GPUBackend.ROCM:
            import torch
            if torch.cuda.is_available():
                device = torch.cuda.current_device()
                total = torch.cuda.get_device_properties(device).total_memory
                allocated = torch.cuda.memory_allocated(device)
                return (total - allocated) / (1024 ** 3)
        elif backend == GPUBackend.MPS:
            import torch
            if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
                return 8.0
        
        return float('inf')

The load_model method orchestrates the entire loading process. It first checks if the model is already loaded to avoid redundant memory usage. Then it validates that the requested backend is supported by the model. Memory availability is checked before proceeding with the actual load operation. The get_available_memory function queries backend-specific APIs to determine free GPU memory. For CUDA and ROCm, PyTorch provides direct memory queries. For MPS, we return a conservative estimate since Apple does not expose detailed memory APIs. The actual model loading is delegated to type-specific loaders.

For transformer models using the Hugging Face transformers library:

    def load_transformers_model(self, model_config: ModelConfig, model_path: Path, backend: GPUBackend) -> Any:
        """Load Hugging Face transformers model"""
        from transformers import AutoModelForCausalLM, AutoTokenizer
        import torch
        
        device_map = self.get_device_map(backend)
        
        load_kwargs = {
            'pretrained_model_name_or_path': str(model_path),
            'device_map': device_map,
            'torch_dtype': torch.float16 if backend != GPUBackend.CPU else torch.float32,
        }
        
        if model_config.quantization == '8bit':
            load_kwargs['load_in_8bit'] = True
        elif model_config.quantization == '4bit':
            load_kwargs['load_in_4bit'] = True
        
        model = AutoModelForCausalLM.from_pretrained(**load_kwargs)
        tokenizer = AutoTokenizer.from_pretrained(str(model_path))
        
        return {'model': model, 'tokenizer': tokenizer, 'type': 'transformers'}
    
    def get_device_map(self, backend: GPUBackend) -> Union[str, Dict]:
        """Get appropriate device map for backend"""
        if backend == GPUBackend.CUDA:
            return 'auto'
        elif backend == GPUBackend.ROCM:
            return 'auto'
        elif backend == GPUBackend.MPS:
            return 'mps'
        elif backend == GPUBackend.INTEL:
            return 'xpu'
        else:
            return 'cpu'

The transformers model loader uses AutoModelForCausalLM which automatically selects the appropriate model architecture based on the configuration files in the model directory. The device_map parameter controls where model layers are placed. Setting it to auto enables automatic device placement across multiple GPUs if available. For MPS, we explicitly specify the mps device. The torch_dtype is set to float16 for GPU backends to reduce memory usage and increase speed, while CPU uses float32 for better numerical stability. Quantization options are applied if specified in the model configuration, using the bitsandbytes library integration in transformers.

For llama.cpp based models which are popular for efficient CPU and GPU inference:

    def load_llama_cpp_model(self, model_config: ModelConfig, model_path: Path, backend: GPUBackend) -> Any:
        """Load llama.cpp model with appropriate backend"""
        try:
            from llama_cpp import Llama
        except ImportError:
            raise ImportError("llama-cpp-python not installed. Install with: pip install llama-cpp-python")
        
        n_gpu_layers = 0
        if backend == GPUBackend.CUDA:
            n_gpu_layers = -1
        elif backend == GPUBackend.ROCM:
            n_gpu_layers = -1
        elif backend == GPUBackend.MPS:
            n_gpu_layers = 1
        
        model_file = self.find_gguf_file(model_path)
        
        llm = Llama(
            model_path=str(model_file),
            n_gpu_layers=n_gpu_layers,
            n_ctx=model_config.context_length,
            n_batch=512,
            verbose=False
        )
        
        return {'model': llm, 'type': 'llama_cpp'}
    
    def find_gguf_file(self, model_path: Path) -> Path:
        """Find GGUF model file in directory"""
        if model_path.is_file() and model_path.suffix in ['.gguf', '.bin']:
            return model_path
        
        for file in model_path.rglob('*.gguf'):
            return file
        
        for file in model_path.rglob('*.bin'):
            return file
        
        raise FileNotFoundError(f"No GGUF or BIN model file found in {model_path}")

The llama.cpp loader uses the llama-cpp-python bindings. The n_gpu_layers parameter controls how many transformer layers are offloaded to the GPU. Setting it to negative one offloads all layers. For MPS, we use a conservative value of one layer due to potential stability issues with full offloading on some Apple Silicon configurations. The find_gguf_file helper searches for GGUF format files which are the standard format for llama.cpp models.

For API-based models that run on remote servers:

    def create_api_client(self, model_config: ModelConfig) -> Any:
        """Create API client for remote model"""
        if not model_config.api_endpoint:
            raise ValueError(f"No API endpoint specified for model {model_config.model_id}")
        
        return {
            'endpoint': model_config.api_endpoint,
            'model_id': model_config.model_id,
            'type': 'api'
        }

API clients are lightweight wrappers that store the endpoint information. The actual inference happens through HTTP requests, so no model loading is required locally.

AUTOMATIC MODEL SWITCHING AND FAILOVER

A robust LLM application must handle situations where the primary model becomes unavailable due to network issues, insufficient resources, or model file corruption. The failover system maintains a prioritized list of alternative models for each task type and automatically switches to a backup when the primary fails.

from typing import List, Callable
import logging

class ModelFailoverManager:
    def __init__(self, model_loader: ModelLoader, registry: ModelRegistry):
        self.model_loader = model_loader
        self.registry = registry
        self.logger = logging.getLogger(__name__)
        self.failover_chains = {}
        self.initialize_failover_chains()
    
    def initialize_failover_chains(self):
        """Create failover chains for different model categories"""
        for model_id, config in self.registry.models.items():
            for capability in config.capabilities:
                if capability not in self.failover_chains:
                    self.failover_chains[capability] = []
                self.failover_chains[capability].append((config.priority, model_id))
        
        for capability in self.failover_chains:
            self.failover_chains[capability].sort(key=lambda x: x[0])
            self.failover_chains[capability] = [model_id for _, model_id in self.failover_chains[capability]]
    
    def get_failover_chain(self, primary_model_id: str) -> List[str]:
        """Get ordered list of fallback models for a primary model"""
        if primary_model_id not in self.registry.models:
            return []
        
        primary_config = self.registry.models[primary_model_id]
        
        candidates = set()
        for capability in primary_config.capabilities:
            if capability in self.failover_chains:
                candidates.update(self.failover_chains[capability])
        
        if primary_model_id in candidates:
            candidates.remove(primary_model_id)
        
        ordered_candidates = []
        for model_id in candidates:
            config = self.registry.models[model_id]
            ordered_candidates.append((config.priority, model_id))
        
        ordered_candidates.sort(key=lambda x: x[0])
        return [model_id for _, model_id in ordered_candidates]

The ModelFailoverManager maintains failover chains organized by capability. When initializing, it groups models by their capabilities and sorts them by priority. The get_failover_chain method returns an ordered list of alternative models that share capabilities with the primary model. This ensures that if the primary model fails, the system can fall back to a model with similar capabilities.

The actual failover logic wraps model loading and inference with retry mechanisms:

    def load_with_failover(self, primary_model_id: str, max_attempts: int = 3) -> Tuple[str, Any]:
        """Attempt to load primary model with automatic failover"""
        failover_chain = [primary_model_id] + self.get_failover_chain(primary_model_id)
        
        for attempt, model_id in enumerate(failover_chain):
            if attempt >= max_attempts:
                break
            
            try:
                self.logger.info(f"Attempting to load model: {model_id}")
                model = self.model_loader.load_model(model_id)
                
                if attempt > 0:
                    self.logger.warning(f"Failed over to model: {model_id}")
                
                return model_id, model
                
            except Exception as e:
                self.logger.error(f"Failed to load model {model_id}: {str(e)}")
                
                if attempt == len(failover_chain) - 1 or attempt >= max_attempts - 1:
                    raise RuntimeError(f"All failover attempts exhausted. Last error: {str(e)}")
                
                continue
        
        raise RuntimeError("Failed to load any model in failover chain")
    
    def execute_with_failover(self, primary_model_id: str, inference_func: Callable, *args, **kwargs) -> Any:
        """Execute inference with automatic model failover on errors"""
        failover_chain = [primary_model_id] + self.get_failover_chain(primary_model_id)
        
        last_exception = None
        for model_id in failover_chain:
            try:
                model = self.model_loader.loaded_models.get(model_id)
                if not model:
                    model_id, model = self.load_with_failover(model_id, max_attempts=1)
                
                result = inference_func(model, *args, **kwargs)
                return result
                
            except Exception as e:
                self.logger.error(f"Inference failed with model {model_id}: {str(e)}")
                last_exception = e
                continue
        
        raise RuntimeError(f"All models in failover chain failed. Last error: {str(last_exception)}")

The load_with_failover method iterates through the failover chain, attempting to load each model in order. If loading succeeds, it returns the model identifier and the loaded model object. If all attempts fail, it raises an exception with details about the last failure. The execute_with_failover method is more sophisticated, wrapping the actual inference function. It attempts inference with each model in the failover chain until one succeeds or all fail. This provides resilience against runtime errors during inference, not just loading failures.

INTELLIGENT MODEL SELECTION BASED ON PROMPTS

The most sophisticated component of the system is the automatic model selector that analyzes user prompts and chooses the most appropriate model. This involves natural language understanding to classify the task type, resource availability checking, and optimization for latency versus quality tradeoffs.

The prompt analyzer extracts features from user input to determine task characteristics:

import re
from typing import Dict, Set

class PromptAnalyzer:
    def __init__(self):
        self.code_patterns = [
            r'```[\w]*\n',
            r'def\s+\w+\s*\(',
            r'class\s+\w+',
            r'function\s+\w+\s*\(',
            r'import\s+\w+',
            r'#include\s*<',
        ]
        
        self.math_patterns = [
            r'\d+\s*[\+\-\*/]\s*\d+',
            r'\\frac\{',
            r'\\int_',
            r'\\sum_',
            r'solve.*equation',
            r'calculate.*derivative',
            r'prove.*theorem',
        ]
        
        self.reasoning_keywords = {
            'explain', 'why', 'how', 'analyze', 'compare', 'evaluate',
            'reasoning', 'logic', 'deduce', 'infer', 'conclude'
        }
        
        self.creative_keywords = {
            'write', 'story', 'poem', 'creative', 'imagine', 'describe',
            'narrative', 'fiction', 'character', 'plot'
        }
    
    def analyze_prompt(self, prompt: str) -> Dict[str, any]:
        """Analyze prompt to extract task characteristics"""
        features = {
            'length': len(prompt),
            'has_code': self.detect_code(prompt),
            'has_math': self.detect_math(prompt),
            'is_reasoning': self.detect_reasoning(prompt),
            'is_creative': self.detect_creative(prompt),
            'estimated_complexity': self.estimate_complexity(prompt),
            'language': self.detect_language(prompt),
        }
        
        return features
    
    def detect_code(self, prompt: str) -> bool:
        """Detect if prompt involves code"""
        for pattern in self.code_patterns:
            if re.search(pattern, prompt, re.IGNORECASE):
                return True
        
        code_keywords = ['code', 'program', 'function', 'algorithm', 'debug', 'implement']
        prompt_lower = prompt.lower()
        return any(keyword in prompt_lower for keyword in code_keywords)
    
    def detect_math(self, prompt: str) -> bool:
        """Detect if prompt involves mathematics"""
        for pattern in self.math_patterns:
            if re.search(pattern, prompt, re.IGNORECASE):
                return True
        
        math_keywords = ['math', 'equation', 'formula', 'calculate', 'solve', 'proof']
        prompt_lower = prompt.lower()
        return any(keyword in prompt_lower for keyword in math_keywords)
    
    def detect_reasoning(self, prompt: str) -> bool:
        """Detect if prompt requires complex reasoning"""
        prompt_lower = prompt.lower()
        words = set(prompt_lower.split())
        
        overlap = words.intersection(self.reasoning_keywords)
        return len(overlap) >= 2 or any(keyword in prompt_lower for keyword in ['step by step', 'think through'])
    
    def detect_creative(self, prompt: str) -> bool:
        """Detect if prompt is creative writing"""
        prompt_lower = prompt.lower()
        words = set(prompt_lower.split())
        
        overlap = words.intersection(self.creative_keywords)
        return len(overlap) >= 1
    
    def estimate_complexity(self, prompt: str) -> str:
        """Estimate task complexity"""
        if len(prompt) > 1000:
            return 'high'
        
        complexity_indicators = [
            self.detect_code(prompt),
            self.detect_math(prompt),
            self.detect_reasoning(prompt),
            len(prompt) > 500,
        ]
        
        if sum(complexity_indicators) >= 3:
            return 'high'
        elif sum(complexity_indicators) >= 1:
            return 'medium'
        else:
            return 'low'
    
    def detect_language(self, prompt: str) -> str:
        """Detect prompt language"""
        try:
            from langdetect import detect
            return detect(prompt)
        except:
            return 'en'

The PromptAnalyzer uses pattern matching and keyword detection to classify prompts. Code detection looks for syntax patterns like function definitions, import statements, and code blocks. Math detection searches for mathematical notation and keywords. Reasoning and creative writing are identified through keyword sets. Complexity estimation combines multiple signals including prompt length and task type. Language detection uses the langdetect library when available, falling back to English as the default.

The model selector uses these features to score and rank available models:

class IntelligentModelSelector:
    def __init__(self, registry: ModelRegistry, model_loader: ModelLoader, 
                 prompt_analyzer: PromptAnalyzer, gpu_detector: GPUDetector):
        self.registry = registry
        self.model_loader = model_loader
        self.prompt_analyzer = prompt_analyzer
        self.gpu_detector = gpu_detector
        self.logger = logging.getLogger(__name__)
    
    def select_model(self, prompt: str, constraints: Optional[Dict] = None) -> str:
        """Select optimal model for given prompt"""
        features = self.prompt_analyzer.analyze_prompt(prompt)
        constraints = constraints or {}
        
        max_latency = constraints.get('max_latency_seconds', float('inf'))
        min_quality = constraints.get('min_quality_score', 0.0)
        preferred_backend = constraints.get('backend', self.gpu_detector.get_optimal_backend())
        
        candidates = self.get_candidate_models(features, preferred_backend)
        
        if not candidates:
            raise ValueError("No suitable models found for this prompt")
        
        scored_candidates = []
        for model_id in candidates:
            score = self.score_model(model_id, features, constraints)
            scored_candidates.append((score, model_id))
        
        scored_candidates.sort(reverse=True, key=lambda x: x[0])
        
        selected_model_id = scored_candidates[0][1]
        self.logger.info(f"Selected model {selected_model_id} with score {scored_candidates[0][0]:.3f}")
        
        return selected_model_id
    
    def get_candidate_models(self, features: Dict, backend: GPUBackend) -> List[str]:
        """Filter models based on features and backend"""
        candidates = []
        
        for model_id, config in self.registry.models.items():
            if backend not in config.supported_backends:
                continue
            
            if features['has_code'] and 'code' not in config.capabilities:
                continue
            
            if features['has_math'] and 'math' not in config.capabilities:
                continue
            
            if features['is_reasoning'] and 'reasoning' not in config.capabilities:
                continue
            
            candidates.append(model_id)
        
        if not candidates:
            candidates = [model_id for model_id, config in self.registry.models.items() 
                         if backend in config.supported_backends]
        
        return candidates

The select_model method orchestrates the selection process. It first analyzes the prompt to extract features, then filters models based on capability requirements and backend compatibility. The get_candidate_models method implements hard constraints, excluding models that lack required capabilities. If no models match all requirements, it falls back to all models supporting the target backend.

Model scoring combines multiple factors:

    def score_model(self, model_id: str, features: Dict, constraints: Dict) -> float:
        """Score model suitability for prompt"""
        config = self.registry.models[model_id]
        score = 0.0
        
        capability_score = self.score_capabilities(config, features)
        score += capability_score * 0.4
        
        resource_score = self.score_resources(config, constraints)
        score += resource_score * 0.3
        
        latency_score = self.score_latency(config, features, constraints)
        score += latency_score * 0.2
        
        priority_score = (100 - config.priority) / 100.0
        score += priority_score * 0.1
        
        return score
    
    def score_capabilities(self, config: ModelConfig, features: Dict) -> float:
        """Score model capabilities match"""
        score = 0.0
        max_score = 0.0
        
        if features['has_code']:
            max_score += 1.0
            if 'code' in config.capabilities:
                score += 1.0
        
        if features['has_math']:
            max_score += 1.0
            if 'math' in config.capabilities:
                score += 1.0
        
        if features['is_reasoning']:
            max_score += 1.0
            if 'reasoning' in config.capabilities:
                score += 1.0
        
        if features['is_creative']:
            max_score += 1.0
            if 'creative' in config.capabilities:
                score += 1.0
        
        if max_score == 0:
            return 1.0
        
        return score / max_score
    
    def score_resources(self, config: ModelConfig, constraints: Dict) -> float:
        """Score based on resource availability"""
        backend = constraints.get('backend', self.gpu_detector.get_optimal_backend())
        
        if backend == GPUBackend.CPU:
            return 1.0
        
        backend_info = self.gpu_detector.backend_info.get(backend, {})
        available_memory = self.model_loader.get_available_memory(backend, backend_info)
        
        if available_memory < config.required_memory_gb:
            return 0.0
        
        memory_ratio = config.required_memory_gb / available_memory
        return 1.0 - (memory_ratio * 0.5)
    
    def score_latency(self, config: ModelConfig, features: Dict, constraints: Dict) -> float:
        """Score based on expected latency"""
        max_latency = constraints.get('max_latency_seconds', float('inf'))
        
        estimated_latency = self.estimate_latency(config, features)
        
        if estimated_latency > max_latency:
            return 0.0
        
        return 1.0 - (estimated_latency / max_latency)
    
    def estimate_latency(self, config: ModelConfig, features: Dict) -> float:
        """Estimate inference latency in seconds"""
        base_latency = config.size_gb * 0.1
        
        if features['estimated_complexity'] == 'high':
            base_latency *= 2.0
        elif features['estimated_complexity'] == 'medium':
            base_latency *= 1.5
        
        if config.quantization in ['8bit', '4bit']:
            base_latency *= 0.7
        
        backend = self.gpu_detector.get_optimal_backend()
        if backend == GPUBackend.CPU:
            base_latency *= 3.0
        elif backend == GPUBackend.MPS:
            base_latency *= 1.2
        
        return base_latency

The scoring system uses weighted components. Capability matching receives the highest weight at forty percent because using a model specialized for the task type produces better results. Resource availability gets thirty percent weight to ensure the model can actually run. Latency considerations receive twenty percent, and the configured priority gets ten percent. The score_capabilities method computes the overlap between required and available capabilities. The score_resources method checks memory availability and penalizes models that consume a large fraction of available memory. The score_latency method estimates inference time based on model size, complexity, quantization, and backend performance characteristics.

INFERENCE EXECUTION WITH UNIFIED INTERFACE

After selecting the appropriate model, the system needs a unified interface for executing inference regardless of the underlying model type or backend. This abstraction layer handles the differences between transformers models, llama.cpp models, and API-based models.

class UnifiedInferenceEngine:
    def __init__(self, model_loader: ModelLoader):
        self.model_loader = model_loader
        self.logger = logging.getLogger(__name__)
    
    def generate(self, model_id: str, prompt: str, generation_config: Optional[Dict] = None) -> str:
        """Generate response using specified model"""
        if model_id not in self.model_loader.loaded_models:
            raise ValueError(f"Model {model_id} not loaded")
        
        model_wrapper = self.model_loader.loaded_models[model_id]
        model_type = model_wrapper['type']
        
        generation_config = generation_config or {}
        
        if model_type == 'transformers':
            return self.generate_transformers(model_wrapper, prompt, generation_config)
        elif model_type == 'llama_cpp':
            return self.generate_llama_cpp(model_wrapper, prompt, generation_config)
        elif model_type == 'api':
            return self.generate_api(model_wrapper, prompt, generation_config)
        else:
            raise ValueError(f"Unknown model type: {model_type}")
    
    def generate_transformers(self, model_wrapper: Dict, prompt: str, config: Dict) -> str:
        """Generate using Hugging Face transformers"""
        import torch
        
        model = model_wrapper['model']
        tokenizer = model_wrapper['tokenizer']
        
        inputs = tokenizer(prompt, return_tensors='pt')
        
        device = next(model.parameters()).device
        inputs = {k: v.to(device) for k, v in inputs.items()}
        
        generation_kwargs = {
            'max_new_tokens': config.get('max_tokens', 512),
            'temperature': config.get('temperature', 0.7),
            'top_p': config.get('top_p', 0.9),
            'do_sample': config.get('do_sample', True),
            'pad_token_id': tokenizer.pad_token_id or tokenizer.eos_token_id,
        }
        
        with torch.no_grad():
            outputs = model.generate(**inputs, **generation_kwargs)
        
        response = tokenizer.decode(outputs[0], skip_special_tokens=True)
        
        if response.startswith(prompt):
            response = response[len(prompt):].strip()
        
        return response
    
    def generate_llama_cpp(self, model_wrapper: Dict, prompt: str, config: Dict) -> str:
        """Generate using llama.cpp"""
        model = model_wrapper['model']
        
        generation_kwargs = {
            'max_tokens': config.get('max_tokens', 512),
            'temperature': config.get('temperature', 0.7),
            'top_p': config.get('top_p', 0.9),
            'echo': False,
        }
        
        response = model(prompt, **generation_kwargs)
        
        return response['choices'][0]['text'].strip()
    
    def generate_api(self, model_wrapper: Dict, prompt: str, config: Dict) -> str:
        """Generate using API endpoint"""
        import requests
        
        endpoint = model_wrapper['endpoint']
        
        payload = {
            'model': model_wrapper['model_id'],
            'prompt': prompt,
            'max_tokens': config.get('max_tokens', 512),
            'temperature': config.get('temperature', 0.7),
            'top_p': config.get('top_p', 0.9),
        }
        
        response = requests.post(endpoint, json=payload, timeout=60)
        response.raise_for_status()
        
        result = response.json()
        
        if 'choices' in result:
            return result['choices'][0]['text'].strip()
        elif 'response' in result:
            return result['response'].strip()
        else:
            return str(result)

The UnifiedInferenceEngine provides a consistent generate method regardless of model type. For transformers models, it tokenizes the input, moves tensors to the appropriate device, generates using the model's generate method, and decodes the output. For llama.cpp models, it calls the model directly with the prompt string. For API models, it constructs an HTTP request with the prompt and generation parameters. The generation_config dictionary allows callers to specify parameters like maximum token count, temperature, and top-p sampling.

COMPLETE INTEGRATION AND ORCHESTRATION

The final component ties everything together into a cohesive system that handles the entire workflow from prompt reception to response generation:

class LLMApplication:
    def __init__(self, registry_path: Optional[Path] = None, cache_dir: Optional[Path] = None):
        self.gpu_detector = GPUDetector()
        self.registry = ModelRegistry(registry_path)
        self.model_loader = ModelLoader(self.registry, self.gpu_detector, cache_dir)
        self.failover_manager = ModelFailoverManager(self.model_loader, self.registry)
        self.prompt_analyzer = PromptAnalyzer()
        self.model_selector = IntelligentModelSelector(
            self.registry, self.model_loader, self.prompt_analyzer, self.gpu_detector
        )
        self.inference_engine = UnifiedInferenceEngine(self.model_loader)
        self.logger = logging.getLogger(__name__)
        
        self.logger.info(f"Initialized with backend: {self.gpu_detector.get_optimal_backend().value}")
    
    def process_prompt(self, prompt: str, model_id: Optional[str] = None, 
                      constraints: Optional[Dict] = None, 
                      generation_config: Optional[Dict] = None) -> Dict:
        """Process prompt with automatic model selection and failover"""
        try:
            if model_id is None:
                model_id = self.model_selector.select_model(prompt, constraints)
                self.logger.info(f"Auto-selected model: {model_id}")
            
            if model_id not in self.model_loader.loaded_models:
                model_id, _ = self.failover_manager.load_with_failover(model_id)
            
            def inference_func(model_wrapper):
                return self.inference_engine.generate(model_id, prompt, generation_config)
            
            response = self.failover_manager.execute_with_failover(
                model_id, inference_func
            )
            
            return {
                'success': True,
                'model_id': model_id,
                'prompt': prompt,
                'response': response,
                'backend': self.gpu_detector.get_optimal_backend().value
            }
            
        except Exception as e:
            self.logger.error(f"Failed to process prompt: {str(e)}")
            return {
                'success': False,
                'error': str(e),
                'prompt': prompt
            }
    
    def add_model(self, model_config: ModelConfig):
        """Add new model to registry"""
        self.registry.models[model_config.model_id] = model_config
        self.registry.save_registry()
        self.failover_manager.initialize_failover_chains()
    
    def remove_model(self, model_id: str):
        """Remove model from registry and unload if loaded"""
        if model_id in self.model_loader.loaded_models:
            del self.model_loader.loaded_models[model_id]
        
        if model_id in self.registry.models:
            del self.registry.models[model_id]
            self.registry.save_registry()
            self.failover_manager.initialize_failover_chains()
    
    def list_models(self) -> List[Dict]:
        """List all registered models"""
        models = []
        for model_id, config in self.registry.models.items():
            models.append({
                'model_id': model_id,
                'type': config.model_type,
                'source': config.source,
                'size_gb': config.size_gb,
                'capabilities': config.capabilities,
                'loaded': model_id in self.model_loader.loaded_models
            })
        return models
    
    def get_system_info(self) -> Dict:
        """Get system information including GPU and loaded models"""
        return {
            'backend': self.gpu_detector.get_optimal_backend().value,
            'available_backends': [b.value for b in self.gpu_detector.available_backends],
            'backend_info': {k.value: v for k, v in self.gpu_detector.backend_info.items()},
            'loaded_models': list(self.model_loader.loaded_models.keys()),
            'registered_models': len(self.registry.models)
        }

The LLMApplication class is the main entry point. Its process_prompt method implements the complete workflow. If no model is specified, it uses the intelligent selector. It ensures the model is loaded using the failover manager. It executes inference with automatic failover on errors. The method returns a dictionary containing the response and metadata about which model and backend were used.

The add_model and remove_model methods provide dynamic model management. Adding a model updates the registry and reinitializes failover chains. Removing a model unloads it from memory and updates the registry. The list_models method provides visibility into available models and their status. The get_system_info method returns comprehensive system information useful for debugging and monitoring.

RUNNING EXAMPLE ADDENDUM

The following is a complete, production-ready implementation that integrates all the components discussed above. This code can be deployed directly and supports all GPU backends, local and remote models, automatic failover, and intelligent model selection.

#!/usr/bin/env python3

import sys
import subprocess
import json
import re
import logging
import hashlib
import requests
from pathlib import Path
from typing import Optional, Dict, List, Tuple, Union, Any, Callable, Set
from dataclasses import dataclass, asdict
from enum import Enum
from tqdm import tqdm

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)


class GPUBackend(Enum):
    """Enumeration of supported GPU backends"""
    CUDA = "cuda"
    ROCM = "rocm"
    MPS = "mps"
    INTEL = "intel"
    CPU = "cpu"


class GPUDetector:
    """Detects available GPU hardware and selects optimal backend"""
    
    def __init__(self):
        self.available_backends = []
        self.backend_info = {}
        self.logger = logging.getLogger(self.__class__.__name__)
        self.detect_all_backends()
    
    def detect_cuda(self) -> Tuple[bool, Dict]:
        """Detect Nvidia CUDA GPUs and gather information"""
        try:
            import torch
            if torch.cuda.is_available():
                device_count = torch.cuda.device_count()
                devices = []
                for i in range(device_count):
                    props = torch.cuda.get_device_properties(i)
                    devices.append({
                        'index': i,
                        'name': props.name,
                        'compute_capability': f"{props.major}.{props.minor}",
                        'total_memory': props.total_memory,
                        'multi_processor_count': props.multi_processor_count
                    })
                self.logger.info(f"Detected {device_count} CUDA device(s)")
                return True, {'device_count': device_count, 'devices': devices}
        except ImportError:
            self.logger.debug("PyTorch not available for CUDA detection")
        except Exception as e:
            self.logger.debug(f"CUDA detection failed: {str(e)}")
        return False, {}
    
    def detect_rocm(self) -> Tuple[bool, Dict]:
        """Detect AMD ROCm GPUs"""
        try:
            import torch
            if hasattr(torch.version, 'hip') and torch.version.hip is not None:
                if torch.cuda.is_available():
                    device_count = torch.cuda.device_count()
                    devices = []
                    for i in range(device_count):
                        props = torch.cuda.get_device_properties(i)
                        devices.append({
                            'index': i,
                            'name': props.name,
                            'total_memory': props.total_memory,
                            'gcn_arch': props.gcnArchName if hasattr(props, 'gcnArchName') else 'unknown'
                        })
                    self.logger.info(f"Detected {device_count} ROCm device(s)")
                    return True, {'device_count': device_count, 'devices': devices}
        except Exception as e:
            self.logger.debug(f"ROCm PyTorch detection failed: {str(e)}")
        
        try:
            result = subprocess.run(['rocm-smi', '--showproductname'], 
                                  capture_output=True, text=True, timeout=5)
            if result.returncode == 0 and result.stdout:
                self.logger.info("Detected ROCm via rocm-smi")
                return True, {'detected_via': 'rocm-smi', 'info': result.stdout.strip()}
        except FileNotFoundError:
            self.logger.debug("rocm-smi not found")
        except Exception as e:
            self.logger.debug(f"rocm-smi detection failed: {str(e)}")
        
        return False, {}
    
    def detect_mps(self) -> Tuple[bool, Dict]:
        """Detect Apple Metal Performance Shaders"""
        try:
            import torch
            if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
                self.logger.info("Detected Apple MPS")
                return True, {'backend': 'mps', 'built': torch.backends.mps.is_built()}
        except Exception as e:
            self.logger.debug(f"MPS detection failed: {str(e)}")
        
        try:
            import platform
            if platform.system() == 'Darwin' and platform.machine() == 'arm64':
                self.logger.info("Detected Apple Silicon platform")
                return True, {'platform': 'Apple Silicon', 'detected_via': 'platform'}
        except Exception as e:
            self.logger.debug(f"Platform detection failed: {str(e)}")
        
        return False, {}
    
    def detect_intel(self) -> Tuple[bool, Dict]:
        """Detect Intel GPUs with oneAPI support"""
        try:
            import torch
            if hasattr(torch, 'xpu') and torch.xpu.is_available():
                device_count = torch.xpu.device_count()
                devices = []
                for i in range(device_count):
                    devices.append({
                        'index': i,
                        'name': torch.xpu.get_device_name(i)
                    })
                self.logger.info(f"Detected {device_count} Intel XPU device(s)")
                return True, {'device_count': device_count, 'devices': devices}
        except Exception as e:
            self.logger.debug(f"Intel XPU detection failed: {str(e)}")
        
        try:
            result = subprocess.run(['sycl-ls'], capture_output=True, text=True, timeout=5)
            if result.returncode == 0 and 'gpu' in result.stdout.lower():
                self.logger.info("Detected Intel GPU via sycl-ls")
                return True, {'detected_via': 'sycl-ls', 'info': result.stdout.strip()}
        except FileNotFoundError:
            self.logger.debug("sycl-ls not found")
        except Exception as e:
            self.logger.debug(f"sycl-ls detection failed: {str(e)}")
        
        return False, {}
    
    def detect_all_backends(self):
        """Detect all available GPU backends and rank them"""
        cuda_available, cuda_info = self.detect_cuda()
        if cuda_available:
            self.available_backends.append(GPUBackend.CUDA)
            self.backend_info[GPUBackend.CUDA] = cuda_info
        
        rocm_available, rocm_info = self.detect_rocm()
        if rocm_available and not cuda_available:
            self.available_backends.append(GPUBackend.ROCM)
            self.backend_info[GPUBackend.ROCM] = rocm_info
        
        mps_available, mps_info = self.detect_mps()
        if mps_available:
            self.available_backends.append(GPUBackend.MPS)
            self.backend_info[GPUBackend.MPS] = mps_info
        
        intel_available, intel_info = self.detect_intel()
        if intel_available:
            self.available_backends.append(GPUBackend.INTEL)
            self.backend_info[GPUBackend.INTEL] = intel_info
        
        self.available_backends.append(GPUBackend.CPU)
        self.backend_info[GPUBackend.CPU] = {'fallback': True}
        
        self.logger.info(f"Available backends: {[b.value for b in self.available_backends]}")
    
    def get_optimal_backend(self) -> GPUBackend:
        """Select the optimal backend based on availability and priority"""
        priority_order = [GPUBackend.CUDA, GPUBackend.ROCM, GPUBackend.MPS, 
                         GPUBackend.INTEL, GPUBackend.CPU]
        
        for backend in priority_order:
            if backend in self.available_backends:
                self.logger.info(f"Selected optimal backend: {backend.value}")
                return backend
        
        return GPUBackend.CPU


@dataclass
class ModelConfig:
    """Configuration for an LLM model"""
    model_id: str
    model_type: str
    source: str
    local_path: Optional[Path]
    remote_url: Optional[str]
    api_endpoint: Optional[str]
    size_gb: float
    quantization: Optional[str]
    context_length: int
    required_memory_gb: float
    supported_backends: List[GPUBackend]
    capabilities: List[str]
    priority: int


class ModelRegistry:
    """Registry for managing available models"""
    
    def __init__(self, registry_path: Optional[Path] = None):
        self.registry_path = registry_path or Path.home() / '.llm_app' / 'models.json'
        self.models = {}
        self.logger = logging.getLogger(self.__class__.__name__)
        self.load_registry()
    
    def load_registry(self):
        """Load model registry from disk"""
        if self.registry_path.exists():
            try:
                with open(self.registry_path, 'r') as f:
                    data = json.load(f)
                    for model_id, config_dict in data.items():
                        config_dict['local_path'] = Path(config_dict['local_path']) if config_dict.get('local_path') else None
                        config_dict['supported_backends'] = [GPUBackend(b) for b in config_dict['supported_backends']]
                        self.models[model_id] = ModelConfig(**config_dict)
                self.logger.info(f"Loaded {len(self.models)} models from registry")
            except Exception as e:
                self.logger.error(f"Failed to load registry: {str(e)}")
                self.initialize_default_registry()
        else:
            self.initialize_default_registry()
    
    def save_registry(self):
        """Persist model registry to disk"""
        self.registry_path.parent.mkdir(parents=True, exist_ok=True)
        data = {}
        for model_id, config in self.models.items():
            config_dict = asdict(config)
            config_dict['local_path'] = str(config_dict['local_path']) if config_dict['local_path'] else None
            config_dict['supported_backends'] = [b.value for b in config_dict['supported_backends']]
            data[model_id] = config_dict
        
        with open(self.registry_path, 'w') as f:
            json.dump(data, f, indent=2)
        
        self.logger.info(f"Saved {len(self.models)} models to registry")
    
    def initialize_default_registry(self):
        """Initialize registry with default models"""
        self.models = {
            'gpt2-small': ModelConfig(
                model_id='gpt2-small',
                model_type='transformers',
                source='huggingface',
                local_path=None,
                remote_url='gpt2',
                api_endpoint=None,
                size_gb=0.5,
                quantization=None,
                context_length=1024,
                required_memory_gb=2.0,
                supported_backends=[GPUBackend.CUDA, GPUBackend.ROCM, GPUBackend.MPS, 
                                  GPUBackend.INTEL, GPUBackend.CPU],
                capabilities=['general', 'creative'],
                priority=50
            ),
            'tinyllama': ModelConfig(
                model_id='tinyllama',
                model_type='transformers',
                source='huggingface',
                local_path=None,
                remote_url='TinyLlama/TinyLlama-1.1B-Chat-v1.0',
                api_endpoint=None,
                size_gb=2.2,
                quantization=None,
                context_length=2048,
                required_memory_gb=4.0,
                supported_backends=[GPUBackend.CUDA, GPUBackend.ROCM, GPUBackend.MPS, 
                                  GPUBackend.INTEL, GPUBackend.CPU],
                capabilities=['general', 'chat', 'reasoning'],
                priority=40
            ),
        }
        self.save_registry()
        self.logger.info("Initialized default model registry")


class ModelLoader:
    """Handles loading models from various sources"""
    
    def __init__(self, registry: ModelRegistry, gpu_detector: GPUDetector, cache_dir: Optional[Path] = None):
        self.registry = registry
        self.gpu_detector = gpu_detector
        self.cache_dir = cache_dir or Path.home() / '.llm_app' / 'cache'
        self.cache_dir.mkdir(parents=True, exist_ok=True)
        self.loaded_models = {}
        self.current_backend = gpu_detector.get_optimal_backend()
        self.logger = logging.getLogger(self.__class__.__name__)
    
    def download_model(self, model_config: ModelConfig) -> Path:
        """Download model from remote source if not cached"""
        if model_config.local_path and model_config.local_path.exists():
            self.logger.info(f"Using local model at {model_config.local_path}")
            return model_config.local_path
        
        if not model_config.remote_url:
            raise ValueError(f"No remote URL available for model {model_config.model_id}")
        
        model_cache_path = self.cache_dir / model_config.model_id
        model_cache_path.mkdir(parents=True, exist_ok=True)
        
        if model_config.source == 'huggingface':
            return self.download_from_huggingface(model_config, model_cache_path)
        else:
            return self.download_from_url(model_config.remote_url, model_cache_path)
    
    def download_from_huggingface(self, model_config: ModelConfig, cache_path: Path) -> Path:
        """Download model from Hugging Face Hub"""
        try:
            from huggingface_hub import snapshot_download
            
            self.logger.info(f"Downloading {model_config.model_id} from Hugging Face")
            
            snapshot_download(
                repo_id=model_config.remote_url,
                cache_dir=str(cache_path),
                local_dir=str(cache_path / 'model'),
                local_dir_use_symlinks=False
            )
            
            final_path = cache_path / 'model'
            model_config.local_path = final_path
            self.registry.save_registry()
            self.logger.info(f"Downloaded model to {final_path}")
            return final_path
            
        except ImportError:
            raise ImportError("huggingface_hub not installed. Install with: pip install huggingface_hub")
        except Exception as e:
            raise RuntimeError(f"Failed to download from Hugging Face: {str(e)}")
    
    def download_from_url(self, url: str, cache_path: Path) -> Path:
        """Download model from direct URL with progress tracking"""
        filename = url.split('/')[-1]
        file_path = cache_path / filename
        
        if file_path.exists():
            self.logger.info(f"Model already cached at {file_path}")
            return file_path
        
        self.logger.info(f"Downloading from {url}")
        response = requests.get(url, stream=True)
        response.raise_for_status()
        
        total_size = int(response.headers.get('content-length', 0))
        
        with open(file_path, 'wb') as f, tqdm(
            desc=filename,
            total=total_size,
            unit='iB',
            unit_scale=True,
            unit_divisor=1024,
        ) as progress_bar:
            for chunk in response.iter_content(chunk_size=8192):
                size = f.write(chunk)
                progress_bar.update(size)
        
        self.logger.info(f"Downloaded to {file_path}")
        return file_path
    
    def get_available_memory(self, backend: GPUBackend, backend_info: Dict) -> float:
        """Get available GPU memory in GB"""
        if backend == GPUBackend.CUDA:
            try:
                import torch
                if torch.cuda.is_available():
                    device = torch.cuda.current_device()
                    total = torch.cuda.get_device_properties(device).total_memory
                    allocated = torch.cuda.memory_allocated(device)
                    available_bytes = total - allocated
                    return available_bytes / (1024 ** 3)
            except Exception as e:
                self.logger.warning(f"Failed to get CUDA memory: {str(e)}")
        elif backend == GPUBackend.ROCM:
            try:
                import torch
                if torch.cuda.is_available():
                    device = torch.cuda.current_device()
                    total = torch.cuda.get_device_properties(device).total_memory
                    allocated = torch.cuda.memory_allocated(device)
                    available_bytes = total - allocated
                    return available_bytes / (1024 ** 3)
            except Exception as e:
                self.logger.warning(f"Failed to get ROCm memory: {str(e)}")
        elif backend == GPUBackend.MPS:
            return 8.0
        
        return float('inf')
    
    def load_model(self, model_id: str, backend: Optional[GPUBackend] = None) -> Any:
        """Load model into memory with appropriate backend"""
        if model_id in self.loaded_models:
            self.logger.info(f"Model {model_id} already loaded")
            return self.loaded_models[model_id]
        
        if model_id not in self.registry.models:
            raise ValueError(f"Model {model_id} not found in registry")
        
        model_config = self.registry.models[model_id]
        target_backend = backend or self.current_backend
        
        if target_backend not in model_config.supported_backends:
            raise ValueError(f"Model {model_id} does not support backend {target_backend.value}")
        
        backend_info = self.gpu_detector.backend_info.get(target_backend, {})
        if target_backend != GPUBackend.CPU:
            available_memory = self.get_available_memory(target_backend, backend_info)
            if available_memory < model_config.required_memory_gb:
                raise RuntimeError(f"Insufficient memory: need {model_config.required_memory_gb}GB, have {available_memory:.1f}GB")
        
        self.logger.info(f"Loading model {model_id} on {target_backend.value}")
        model_path = self.download_model(model_config)
        
        if model_config.model_type == 'transformers':
            model = self.load_transformers_model(model_config, model_path, target_backend)
        elif model_config.model_type == 'llama_cpp':
            model = self.load_llama_cpp_model(model_config, model_path, target_backend)
        elif model_config.model_type == 'api':
            model = self.create_api_client(model_config)
        else:
            raise ValueError(f"Unsupported model type: {model_config.model_type}")
        
        self.loaded_models[model_id] = model
        self.logger.info(f"Successfully loaded model {model_id}")
        return model
    
    def get_device_map(self, backend: GPUBackend) -> Union[str, Dict]:
        """Get appropriate device map for backend"""
        if backend == GPUBackend.CUDA:
            return 'auto'
        elif backend == GPUBackend.ROCM:
            return 'auto'
        elif backend == GPUBackend.MPS:
            return 'mps'
        elif backend == GPUBackend.INTEL:
            return 'xpu'
        else:
            return 'cpu'
    
    def load_transformers_model(self, model_config: ModelConfig, model_path: Path, backend: GPUBackend) -> Any:
        """Load Hugging Face transformers model"""
        try:
            from transformers import AutoModelForCausalLM, AutoTokenizer
            import torch
        except ImportError:
            raise ImportError("transformers and torch not installed. Install with: pip install transformers torch")
        
        device_map = self.get_device_map(backend)
        
        load_kwargs = {
            'pretrained_model_name_or_path': str(model_path),
            'device_map': device_map,
            'torch_dtype': torch.float16 if backend != GPUBackend.CPU else torch.float32,
            'low_cpu_mem_usage': True,
        }
        
        if model_config.quantization == '8bit':
            load_kwargs['load_in_8bit'] = True
        elif model_config.quantization == '4bit':
            load_kwargs['load_in_4bit'] = True
        
        model = AutoModelForCausalLM.from_pretrained(**load_kwargs)
        tokenizer = AutoTokenizer.from_pretrained(str(model_path))
        
        if tokenizer.pad_token is None:
            tokenizer.pad_token = tokenizer.eos_token
        
        return {'model': model, 'tokenizer': tokenizer, 'type': 'transformers'}
    
    def find_gguf_file(self, model_path: Path) -> Path:
        """Find GGUF model file in directory"""
        if model_path.is_file() and model_path.suffix in ['.gguf', '.bin']:
            return model_path
        
        for file in model_path.rglob('*.gguf'):
            return file
        
        for file in model_path.rglob('*.bin'):
            return file
        
        raise FileNotFoundError(f"No GGUF or BIN model file found in {model_path}")
    
    def load_llama_cpp_model(self, model_config: ModelConfig, model_path: Path, backend: GPUBackend) -> Any:
        """Load llama.cpp model with appropriate backend"""
        try:
            from llama_cpp import Llama
        except ImportError:
            raise ImportError("llama-cpp-python not installed. Install with: pip install llama-cpp-python")
        
        n_gpu_layers = 0
        if backend == GPUBackend.CUDA:
            n_gpu_layers = -1
        elif backend == GPUBackend.ROCM:
            n_gpu_layers = -1
        elif backend == GPUBackend.MPS:
            n_gpu_layers = 1
        
        model_file = self.find_gguf_file(model_path)
        
        llm = Llama(
            model_path=str(model_file),
            n_gpu_layers=n_gpu_layers,
            n_ctx=model_config.context_length,
            n_batch=512,
            verbose=False
        )
        
        return {'model': llm, 'type': 'llama_cpp'}
    
    def create_api_client(self, model_config: ModelConfig) -> Any:
        """Create API client for remote model"""
        if not model_config.api_endpoint:
            raise ValueError(f"No API endpoint specified for model {model_config.model_id}")
        
        return {
            'endpoint': model_config.api_endpoint,
            'model_id': model_config.model_id,
            'type': 'api'
        }
    
    def unload_model(self, model_id: str):
        """Unload model from memory"""
        if model_id in self.loaded_models:
            del self.loaded_models[model_id]
            self.logger.info(f"Unloaded model {model_id}")
            
            try:
                import torch
                if torch.cuda.is_available():
                    torch.cuda.empty_cache()
            except:
                pass


class ModelFailoverManager:
    """Manages automatic model failover"""
    
    def __init__(self, model_loader: ModelLoader, registry: ModelRegistry):
        self.model_loader = model_loader
        self.registry = registry
        self.logger = logging.getLogger(self.__class__.__name__)
        self.failover_chains = {}
        self.initialize_failover_chains()
    
    def initialize_failover_chains(self):
        """Create failover chains for different model categories"""
        for model_id, config in self.registry.models.items():
            for capability in config.capabilities:
                if capability not in self.failover_chains:
                    self.failover_chains[capability] = []
                self.failover_chains[capability].append((config.priority, model_id))
        
        for capability in self.failover_chains:
            self.failover_chains[capability].sort(key=lambda x: x[0])
            self.failover_chains[capability] = [model_id for _, model_id in self.failover_chains[capability]]
        
        self.logger.info(f"Initialized failover chains for {len(self.failover_chains)} capabilities")
    
    def get_failover_chain(self, primary_model_id: str) -> List[str]:
        """Get ordered list of fallback models for a primary model"""
        if primary_model_id not in self.registry.models:
            return []
        
        primary_config = self.registry.models[primary_model_id]
        
        candidates = set()
        for capability in primary_config.capabilities:
            if capability in self.failover_chains:
                candidates.update(self.failover_chains[capability])
        
        if primary_model_id in candidates:
            candidates.remove(primary_model_id)
        
        ordered_candidates = []
        for model_id in candidates:
            config = self.registry.models[model_id]
            ordered_candidates.append((config.priority, model_id))
        
        ordered_candidates.sort(key=lambda x: x[0])
        return [model_id for _, model_id in ordered_candidates]
    
    def load_with_failover(self, primary_model_id: str, max_attempts: int = 3) -> Tuple[str, Any]:
        """Attempt to load primary model with automatic failover"""
        failover_chain = [primary_model_id] + self.get_failover_chain(primary_model_id)
        
        for attempt, model_id in enumerate(failover_chain):
            if attempt >= max_attempts:
                break
            
            try:
                self.logger.info(f"Attempting to load model: {model_id}")
                model = self.model_loader.load_model(model_id)
                
                if attempt > 0:
                    self.logger.warning(f"Failed over to model: {model_id}")
                
                return model_id, model
                
            except Exception as e:
                self.logger.error(f"Failed to load model {model_id}: {str(e)}")
                
                if attempt == len(failover_chain) - 1 or attempt >= max_attempts - 1:
                    raise RuntimeError(f"All failover attempts exhausted. Last error: {str(e)}")
                
                continue
        
        raise RuntimeError("Failed to load any model in failover chain")
    
    def execute_with_failover(self, primary_model_id: str, inference_func: Callable, *args, **kwargs) -> Any:
        """Execute inference with automatic model failover on errors"""
        failover_chain = [primary_model_id] + self.get_failover_chain(primary_model_id)
        
        last_exception = None
        for model_id in failover_chain:
            try:
                model = self.model_loader.loaded_models.get(model_id)
                if not model:
                    model_id, model = self.load_with_failover(model_id, max_attempts=1)
                
                result = inference_func(model, *args, **kwargs)
                return result
                
            except Exception as e:
                self.logger.error(f"Inference failed with model {model_id}: {str(e)}")
                last_exception = e
                continue
        
        raise RuntimeError(f"All models in failover chain failed. Last error: {str(last_exception)}")


class PromptAnalyzer:
    """Analyzes prompts to determine task characteristics"""
    
    def __init__(self):
        self.code_patterns = [
            r'```[\w]*\n',
            r'def\s+\w+\s*\(',
            r'class\s+\w+',
            r'function\s+\w+\s*\(',
            r'import\s+\w+',
            r'#include\s*<',
            r'public\s+class',
            r'fn\s+\w+\s*\(',
        ]
        
        self.math_patterns = [
            r'\d+\s*[\+\-\*/]\s*\d+',
            r'\\frac\{',
            r'\\int_',
            r'\\sum_',
            r'solve.*equation',
            r'calculate.*derivative',
            r'prove.*theorem',
            r'\d+\^\d+',
        ]
        
        self.reasoning_keywords = {
            'explain', 'why', 'how', 'analyze', 'compare', 'evaluate',
            'reasoning', 'logic', 'deduce', 'infer', 'conclude', 'because',
            'therefore', 'thus', 'hence', 'consequently'
        }
        
        self.creative_keywords = {
            'write', 'story', 'poem', 'creative', 'imagine', 'describe',
            'narrative', 'fiction', 'character', 'plot', 'scene', 'dialogue'
        }
        
        self.logger = logging.getLogger(self.__class__.__name__)
    
    def analyze_prompt(self, prompt: str) -> Dict[str, any]:
        """Analyze prompt to extract task characteristics"""
        features = {
            'length': len(prompt),
            'has_code': self.detect_code(prompt),
            'has_math': self.detect_math(prompt),
            'is_reasoning': self.detect_reasoning(prompt),
            'is_creative': self.detect_creative(prompt),
            'estimated_complexity': self.estimate_complexity(prompt),
            'language': self.detect_language(prompt),
        }
        
        self.logger.debug(f"Prompt features: {features}")
        return features
    
    def detect_code(self, prompt: str) -> bool:
        """Detect if prompt involves code"""
        for pattern in self.code_patterns:
            if re.search(pattern, prompt, re.IGNORECASE):
                return True
        
        code_keywords = ['code', 'program', 'function', 'algorithm', 'debug', 'implement', 
                        'script', 'syntax', 'compile', 'execute']
        prompt_lower = prompt.lower()
        return any(keyword in prompt_lower for keyword in code_keywords)
    
    def detect_math(self, prompt: str) -> bool:
        """Detect if prompt involves mathematics"""
        for pattern in self.math_patterns:
            if re.search(pattern, prompt, re.IGNORECASE):
                return True
        
        math_keywords = ['math', 'equation', 'formula', 'calculate', 'solve', 'proof',
                        'algebra', 'calculus', 'geometry', 'statistics', 'probability']
        prompt_lower = prompt.lower()
        return any(keyword in prompt_lower for keyword in math_keywords)
    
    def detect_reasoning(self, prompt: str) -> bool:
        """Detect if prompt requires complex reasoning"""
        prompt_lower = prompt.lower()
        words = set(prompt_lower.split())
        
        overlap = words.intersection(self.reasoning_keywords)
        return len(overlap) >= 2 or any(phrase in prompt_lower for phrase in ['step by step', 'think through', 'let\'s think'])
    
    def detect_creative(self, prompt: str) -> bool:
        """Detect if prompt is creative writing"""
        prompt_lower = prompt.lower()
        words = set(prompt_lower.split())
        
        overlap = words.intersection(self.creative_keywords)
        return len(overlap) >= 1
    
    def estimate_complexity(self, prompt: str) -> str:
        """Estimate task complexity"""
        if len(prompt) > 1000:
            return 'high'
        
        complexity_indicators = [
            self.detect_code(prompt),
            self.detect_math(prompt),
            self.detect_reasoning(prompt),
            len(prompt) > 500,
        ]
        
        indicator_count = sum(complexity_indicators)
        
        if indicator_count >= 3:
            return 'high'
        elif indicator_count >= 1:
            return 'medium'
        else:
            return 'low'
    
    def detect_language(self, prompt: str) -> str:
        """Detect prompt language"""
        try:
            from langdetect import detect
            return detect(prompt)
        except:
            return 'en'


class IntelligentModelSelector:
    """Selects optimal model based on prompt analysis"""
    
    def __init__(self, registry: ModelRegistry, model_loader: ModelLoader, 
                 prompt_analyzer: PromptAnalyzer, gpu_detector: GPUDetector):
        self.registry = registry
        self.model_loader = model_loader
        self.prompt_analyzer = prompt_analyzer
        self.gpu_detector = gpu_detector
        self.logger = logging.getLogger(self.__class__.__name__)
    
    def select_model(self, prompt: str, constraints: Optional[Dict] = None) -> str:
        """Select optimal model for given prompt"""
        features = self.prompt_analyzer.analyze_prompt(prompt)
        constraints = constraints or {}
        
        max_latency = constraints.get('max_latency_seconds', float('inf'))
        min_quality = constraints.get('min_quality_score', 0.0)
        preferred_backend = constraints.get('backend', self.gpu_detector.get_optimal_backend())
        
        candidates = self.get_candidate_models(features, preferred_backend)
        
        if not candidates:
            self.logger.warning("No suitable models found, using all available models")
            candidates = list(self.registry.models.keys())
        
        if not candidates:
            raise ValueError("No models available in registry")
        
        scored_candidates = []
        for model_id in candidates:
            score = self.score_model(model_id, features, constraints)
            scored_candidates.append((score, model_id))
        
        scored_candidates.sort(reverse=True, key=lambda x: x[0])
        
        selected_model_id = scored_candidates[0][1]
        self.logger.info(f"Selected model {selected_model_id} with score {scored_candidates[0][0]:.3f}")
        
        return selected_model_id
    
    def get_candidate_models(self, features: Dict, backend: GPUBackend) -> List[str]:
        """Filter models based on features and backend"""
        candidates = []
        
        for model_id, config in self.registry.models.items():
            if backend not in config.supported_backends:
                continue
            
            if features['has_code'] and 'code' not in config.capabilities:
                continue
            
            if features['has_math'] and 'math' not in config.capabilities:
                continue
            
            if features['is_reasoning'] and 'reasoning' not in config.capabilities:
                continue
            
            candidates.append(model_id)
        
        if not candidates:
            candidates = [model_id for model_id, config in self.registry.models.items() 
                         if backend in config.supported_backends]
        
        return candidates
    
    def score_model(self, model_id: str, features: Dict, constraints: Dict) -> float:
        """Score model suitability for prompt"""
        config = self.registry.models[model_id]
        score = 0.0
        
        capability_score = self.score_capabilities(config, features)
        score += capability_score * 0.4
        
        resource_score = self.score_resources(config, constraints)
        score += resource_score * 0.3
        
        latency_score = self.score_latency(config, features, constraints)
        score += latency_score * 0.2
        
        priority_score = (100 - config.priority) / 100.0
        score += priority_score * 0.1
        
        return score
    
    def score_capabilities(self, config: ModelConfig, features: Dict) -> float:
        """Score model capabilities match"""
        score = 0.0
        max_score = 0.0
        
        if features['has_code']:
            max_score += 1.0
            if 'code' in config.capabilities:
                score += 1.0
        
        if features['has_math']:
            max_score += 1.0
            if 'math' in config.capabilities:
                score += 1.0
        
        if features['is_reasoning']:
            max_score += 1.0
            if 'reasoning' in config.capabilities:
                score += 1.0
        
        if features['is_creative']:
            max_score += 1.0
            if 'creative' in config.capabilities:
                score += 1.0
        
        if max_score == 0:
            return 1.0
        
        return score / max_score
    
    def score_resources(self, config: ModelConfig, constraints: Dict) -> float:
        """Score based on resource availability"""
        backend = constraints.get('backend', self.gpu_detector.get_optimal_backend())
        
        if backend == GPUBackend.CPU:
            return 1.0
        
        backend_info = self.gpu_detector.backend_info.get(backend, {})
        available_memory = self.model_loader.get_available_memory(backend, backend_info)
        
        if available_memory < config.required_memory_gb:
            return 0.0
        
        memory_ratio = config.required_memory_gb / available_memory
        return 1.0 - (memory_ratio * 0.5)
    
    def score_latency(self, config: ModelConfig, features: Dict, constraints: Dict) -> float:
        """Score based on expected latency"""
        max_latency = constraints.get('max_latency_seconds', float('inf'))
        
        estimated_latency = self.estimate_latency(config, features)
        
        if estimated_latency > max_latency:
            return 0.0
        
        return 1.0 - (estimated_latency / max_latency)
    
    def estimate_latency(self, config: ModelConfig, features: Dict) -> float:
        """Estimate inference latency in seconds"""
        base_latency = config.size_gb * 0.1
        
        if features['estimated_complexity'] == 'high':
            base_latency *= 2.0
        elif features['estimated_complexity'] == 'medium':
            base_latency *= 1.5
        
        if config.quantization in ['8bit', '4bit']:
            base_latency *= 0.7
        
        backend = self.gpu_detector.get_optimal_backend()
        if backend == GPUBackend.CPU:
            base_latency *= 3.0
        elif backend == GPUBackend.MPS:
            base_latency *= 1.2
        
        return base_latency


class UnifiedInferenceEngine:
    """Unified interface for model inference"""
    
    def __init__(self, model_loader: ModelLoader):
        self.model_loader = model_loader
        self.logger = logging.getLogger(self.__class__.__name__)
    
    def generate(self, model_id: str, prompt: str, generation_config: Optional[Dict] = None) -> str:
        """Generate response using specified model"""
        if model_id not in self.model_loader.loaded_models:
            raise ValueError(f"Model {model_id} not loaded")
        
        model_wrapper = self.model_loader.loaded_models[model_id]
        model_type = model_wrapper['type']
        
        generation_config = generation_config or {}
        
        self.logger.info(f"Generating with model {model_id} (type: {model_type})")
        
        if model_type == 'transformers':
            return self.generate_transformers(model_wrapper, prompt, generation_config)
        elif model_type == 'llama_cpp':
            return self.generate_llama_cpp(model_wrapper, prompt, generation_config)
        elif model_type == 'api':
            return self.generate_api(model_wrapper, prompt, generation_config)
        else:
            raise ValueError(f"Unknown model type: {model_type}")
    
    def generate_transformers(self, model_wrapper: Dict, prompt: str, config: Dict) -> str:
        """Generate using Hugging Face transformers"""
        import torch
        
        model = model_wrapper['model']
        tokenizer = model_wrapper['tokenizer']
        
        inputs = tokenizer(prompt, return_tensors='pt', padding=True, truncation=True)
        
        device = next(model.parameters()).device
        inputs = {k: v.to(device) for k, v in inputs.items()}
        
        generation_kwargs = {
            'max_new_tokens': config.get('max_tokens', 512),
            'temperature': config.get('temperature', 0.7),
            'top_p': config.get('top_p', 0.9),
            'do_sample': config.get('do_sample', True),
            'pad_token_id': tokenizer.pad_token_id or tokenizer.eos_token_id,
        }
        
        with torch.no_grad():
            outputs = model.generate(**inputs, **generation_kwargs)
        
        response = tokenizer.decode(outputs[0], skip_special_tokens=True)
        
        if response.startswith(prompt):
            response = response[len(prompt):].strip()
        
        return response
    
    def generate_llama_cpp(self, model_wrapper: Dict, prompt: str, config: Dict) -> str:
        """Generate using llama.cpp"""
        model = model_wrapper['model']
        
        generation_kwargs = {
            'max_tokens': config.get('max_tokens', 512),
            'temperature': config.get('temperature', 0.7),
            'top_p': config.get('top_p', 0.9),
            'echo': False,
        }
        
        response = model(prompt, **generation_kwargs)
        
        return response['choices'][0]['text'].strip()
    
    def generate_api(self, model_wrapper: Dict, prompt: str, config: Dict) -> str:
        """Generate using API endpoint"""
        import requests
        
        endpoint = model_wrapper['endpoint']
        
        payload = {
            'model': model_wrapper['model_id'],
            'prompt': prompt,
            'max_tokens': config.get('max_tokens', 512),
            'temperature': config.get('temperature', 0.7),
            'top_p': config.get('top_p', 0.9),
        }
        
        response = requests.post(endpoint, json=payload, timeout=60)
        response.raise_for_status()
        
        result = response.json()
        
        if 'choices' in result:
            return result['choices'][0]['text'].strip()
        elif 'response' in result:
            return result['response'].strip()
        else:
            return str(result)


class LLMApplication:
    """Main application class integrating all components"""
    
    def __init__(self, registry_path: Optional[Path] = None, cache_dir: Optional[Path] = None):
        self.logger = logging.getLogger(self.__class__.__name__)
        
        self.logger.info("Initializing LLM Application")
        self.gpu_detector = GPUDetector()
        self.registry = ModelRegistry(registry_path)
        self.model_loader = ModelLoader(self.registry, self.gpu_detector, cache_dir)
        self.failover_manager = ModelFailoverManager(self.model_loader, self.registry)
        self.prompt_analyzer = PromptAnalyzer()
        self.model_selector = IntelligentModelSelector(
            self.registry, self.model_loader, self.prompt_analyzer, self.gpu_detector
        )
        self.inference_engine = UnifiedInferenceEngine(self.model_loader)
        
        self.logger.info(f"Initialized with backend: {self.gpu_detector.get_optimal_backend().value}")
    
    def process_prompt(self, prompt: str, model_id: Optional[str] = None, 
                      constraints: Optional[Dict] = None, 
                      generation_config: Optional[Dict] = None) -> Dict:
        """Process prompt with automatic model selection and failover"""
        try:
            if model_id is None:
                model_id = self.model_selector.select_model(prompt, constraints)
                self.logger.info(f"Auto-selected model: {model_id}")
            
            if model_id not in self.model_loader.loaded_models:
                model_id, _ = self.failover_manager.load_with_failover(model_id)
            
            def inference_func(model_wrapper):
                return self.inference_engine.generate(model_id, prompt, generation_config)
            
            response = self.failover_manager.execute_with_failover(
                model_id, inference_func
            )
            
            return {
                'success': True,
                'model_id': model_id,
                'prompt': prompt,
                'response': response,
                'backend': self.gpu_detector.get_optimal_backend().value
            }
            
        except Exception as e:
            self.logger.error(f"Failed to process prompt: {str(e)}")
            return {
                'success': False,
                'error': str(e),
                'prompt': prompt
            }
    
    def add_model(self, model_config: ModelConfig):
        """Add new model to registry"""
        self.registry.models[model_config.model_id] = model_config
        self.registry.save_registry()
        self.failover_manager.initialize_failover_chains()
        self.logger.info(f"Added model {model_config.model_id} to registry")
    
    def remove_model(self, model_id: str):
        """Remove model from registry and unload if loaded"""
        if model_id in self.model_loader.loaded_models:
            self.model_loader.unload_model(model_id)
        
        if model_id in self.registry.models:
            del self.registry.models[model_id]
            self.registry.save_registry()
            self.failover_manager.initialize_failover_chains()
            self.logger.info(f"Removed model {model_id} from registry")
    
    def list_models(self) -> List[Dict]:
        """List all registered models"""
        models = []
        for model_id, config in self.registry.models.items():
            models.append({
                'model_id': model_id,
                'type': config.model_type,
                'source': config.source,
                'size_gb': config.size_gb,
                'capabilities': config.capabilities,
                'loaded': model_id in self.model_loader.loaded_models,
                'priority': config.priority
            })
        return models
    
    def get_system_info(self) -> Dict:
        """Get system information including GPU and loaded models"""
        return {
            'backend': self.gpu_detector.get_optimal_backend().value,
            'available_backends': [b.value for b in self.gpu_detector.available_backends],
            'backend_info': {k.value: v for k, v in self.gpu_detector.backend_info.items()},
            'loaded_models': list(self.model_loader.loaded_models.keys()),
            'registered_models': len(self.registry.models)
        }


def main():
    """Example usage of the LLM Application"""
    app = LLMApplication()
    
    print("System Information:")
    print(json.dumps(app.get_system_info(), indent=2))
    print("\n" + "="*80 + "\n")
    
    print("Available Models:")
    for model in app.list_models():
        print(f"  - {model['model_id']}: {model['capabilities']} (loaded: {model['loaded']})")
    print("\n" + "="*80 + "\n")
    
    test_prompts = [
        "Explain how photosynthesis works in plants.",
        "Write a Python function to calculate fibonacci numbers.",
        "Solve the equation: 2x + 5 = 15",
    ]
    
    for i, prompt in enumerate(test_prompts, 1):
        print(f"Test {i}: {prompt}")
        result = app.process_prompt(prompt)
        
        if result['success']:
            print(f"Model: {result['model_id']}")
            print(f"Backend: {result['backend']}")
            print(f"Response: {result['response'][:200]}...")
        else:
            print(f"Error: {result['error']}")
        
        print("\n" + "="*80 + "\n")


if __name__ == "__main__":
    main()

This complete implementation provides a production-ready system for intelligent LLM application deployment. The code handles GPU detection across all major vendors, supports multiple model formats and sources, implements robust failover mechanisms, and automatically selects optimal models based on prompt analysis. The system is extensible, allowing new models to be added dynamically, and provides comprehensive logging and error handling throughout. All components follow clean architecture principles with clear separation of concerns and well-defined interfaces between modules.