Explorar el Código

openai connector (#396)

Bernt Christian Egeland hace 2 semanas
padre
commit
9dbd5367da

+ 13 - 0
messages/de/integrations.json

@@ -479,6 +479,19 @@
         "modelHelp": "Welches Modell antwortet. Die Liste kommt aus Ihrem Konto, neueste zuerst. Kleinere Modelle kosten pro Aufruf weniger und reichen für die meisten Werkstatttexte."
       }
     },
+    "openai-compatible": {
+      "description": "Servicebeschreibungen, Verlaufszusammenfassungen, Dokumentenerkennung und der Assistent, beantwortet von jedem Server, der die Chat-API von OpenAI spricht: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM und ähnliche. Ihre Daten bleiben auf Ihrer eigenen Hardware.",
+      "fields": {
+        "baseUrl": "Basis-URL",
+        "baseUrlHelp": "Wo die OpenAI-artige API des Servers beginnt, inklusive Pfad: /v1 bei Ollama, vLLM, LocalAI und LM Studio, /api bei Open WebUI. Torqvoice hängt /models und /chat/completions daran an.",
+        "apiKey": "API-Schlüssel",
+        "apiKeyHelp": "Nur wenn der Server einen verlangt. Ollama und vLLM laufen meist ohne Schlüssel; Open WebUI stellt einen unter Einstellungen, Konto aus."
+      },
+      "settings": {
+        "model": "Modell",
+        "modelHelp": "Welches Modell antwortet. Die Liste enthält alles, was der Server meldet, nach Namen. Für das Lesen von Fotos ein Modell mit Bildverständnis wählen."
+      }
+    },
     "stripe": {
       "description": "Kartenzahlungen über den Rechnungslink auf Ihr eigenes Stripe-Konto. Der Kunde zahlt, Stripe meldet es Torqvoice, und die Rechnung wird von selbst als bezahlt markiert.",
       "fields": {

+ 13 - 0
messages/en/integrations.json

@@ -479,6 +479,19 @@
         "modelHelp": "Which model answers. The list is read from your account, newest first. Smaller models cost less per call and are enough for most workshop text."
       }
     },
+    "openai-compatible": {
+      "description": "Service descriptions, history summaries, document scanning and the assistant, answered by any server that speaks OpenAI's chat API: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM and the like. Your data stays on your own hardware.",
+      "fields": {
+        "baseUrl": "Base URL",
+        "baseUrlHelp": "Where the server's OpenAI-style API starts, including the path: /v1 for Ollama, vLLM, LocalAI and LM Studio, /api for Open WebUI. Torqvoice appends /models and /chat/completions to it.",
+        "apiKey": "API key",
+        "apiKeyHelp": "Only if the server asks for one. Ollama and vLLM usually run without a key; Open WebUI issues one under Settings, Account."
+      },
+      "settings": {
+        "model": "Model",
+        "modelHelp": "Which model answers. The list is everything the server reports, by name. Use a model with vision if you want photos read."
+      }
+    },
     "stripe": {
       "description": "Card payments from the invoice link, into your own Stripe account. The customer pays, Stripe tells Torqvoice, and the invoice marks itself paid.",
       "fields": {

+ 13 - 0
messages/es/integrations.json

@@ -479,6 +479,19 @@
         "modelHelp": "Qué modelo responde. La lista se lee de tu cuenta, primero los más nuevos. Los modelos pequeños cuestan menos por llamada y bastan para la mayoría de los textos del taller."
       }
     },
+    "openai-compatible": {
+      "description": "Descripciones de servicio, resúmenes de historial, lectura de documentos y el asistente, respondidos por cualquier servidor que hable la API de chat de OpenAI: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM y similares. Sus datos se quedan en su propio hardware.",
+      "fields": {
+        "baseUrl": "URL base",
+        "baseUrlHelp": "Donde empieza la API estilo OpenAI del servidor, incluida la ruta: /v1 en Ollama, vLLM, LocalAI y LM Studio, /api en Open WebUI. Torqvoice añade /models y /chat/completions.",
+        "apiKey": "Clave API",
+        "apiKeyHelp": "Solo si el servidor la pide. Ollama y vLLM suelen funcionar sin clave; Open WebUI emite una en Ajustes, Cuenta."
+      },
+      "settings": {
+        "model": "Modelo",
+        "modelHelp": "Qué modelo responde. La lista es todo lo que el servidor informa, por nombre. Use un modelo con visión si quiere que lea fotos."
+      }
+    },
     "stripe": {
       "description": "Pagos con tarjeta desde el enlace de la factura, a tu propia cuenta de Stripe. El cliente paga, Stripe avisa a Torqvoice y la factura se marca como pagada sola.",
       "fields": {

+ 13 - 0
messages/fr/integrations.json

@@ -479,6 +479,19 @@
         "modelHelp": "Le modèle qui répond. La liste vient de votre compte, les plus récents en premier. Les petits modèles coûtent moins par appel et suffisent à la plupart des textes d'atelier."
       }
     },
+    "openai-compatible": {
+      "description": "Descriptions de service, résumés d'historique, lecture de documents et assistant, fournis par tout serveur parlant l'API de chat d'OpenAI : Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM et consorts. Vos données restent sur votre propre matériel.",
+      "fields": {
+        "baseUrl": "URL de base",
+        "baseUrlHelp": "Là où commence l'API façon OpenAI du serveur, chemin compris : /v1 pour Ollama, vLLM, LocalAI et LM Studio, /api pour Open WebUI. Torqvoice y ajoute /models et /chat/completions.",
+        "apiKey": "Clé API",
+        "apiKeyHelp": "Seulement si le serveur en exige une. Ollama et vLLM tournent généralement sans clé ; Open WebUI en délivre une dans Paramètres, Compte."
+      },
+      "settings": {
+        "model": "Modèle",
+        "modelHelp": "Quel modèle répond. La liste reprend tout ce que le serveur annonce, par nom. Choisissez un modèle avec vision pour la lecture de photos."
+      }
+    },
     "stripe": {
       "description": "Paiements par carte depuis le lien de la facture, sur votre propre compte Stripe. Le client paie, Stripe prévient Torqvoice et la facture se marque payée toute seule.",
       "fields": {

+ 13 - 0
messages/it/integrations.json

@@ -479,6 +479,19 @@
         "modelHelp": "Quale modello risponde. L'elenco arriva dal vostro account, prima i più recenti. I modelli piccoli costano meno a chiamata e bastano per quasi tutti i testi dell'officina."
       }
     },
+    "openai-compatible": {
+      "description": "Descrizioni di servizio, riepiloghi dello storico, lettura di documenti e assistente, forniti da qualsiasi server che parli l'API chat di OpenAI: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM e simili. I tuoi dati restano sul tuo hardware.",
+      "fields": {
+        "baseUrl": "URL di base",
+        "baseUrlHelp": "Dove inizia l'API in stile OpenAI del server, percorso incluso: /v1 per Ollama, vLLM, LocalAI e LM Studio, /api per Open WebUI. Torqvoice vi aggiunge /models e /chat/completions.",
+        "apiKey": "Chiave API",
+        "apiKeyHelp": "Solo se il server la richiede. Ollama e vLLM di solito funzionano senza chiave; Open WebUI ne rilascia una in Impostazioni, Account."
+      },
+      "settings": {
+        "model": "Modello",
+        "modelHelp": "Quale modello risponde. L'elenco è tutto ciò che il server riporta, per nome. Usa un modello con visione se vuoi che legga le foto."
+      }
+    },
     "stripe": {
       "description": "Pagamenti con carta dal link della fattura, sul tuo account Stripe. Il cliente paga, Stripe avvisa Torqvoice e la fattura si segna pagata da sola.",
       "fields": {

+ 13 - 0
messages/lt/integrations.json

@@ -479,6 +479,19 @@
         "modelHelp": "Kuris modelis atsako. Sąrašas nuskaitomas iš jūsų paskyros, naujausi viršuje. Mažesni modeliai kainuoja mažiau ir daugumai serviso tekstų visiškai pakanka."
       }
     },
+    "openai-compatible": {
+      "description": "Paslaugų aprašymai, istorijos santraukos, dokumentų nuskaitymas ir asistentas, kuriuos atsako bet kuris serveris, kalbantis OpenAI pokalbių API: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM ir panašūs. Jūsų duomenys lieka jūsų pačių įrangoje.",
+      "fields": {
+        "baseUrl": "Bazinis URL",
+        "baseUrlHelp": "Kur prasideda serverio OpenAI tipo API, įskaitant kelią: /v1 Ollama, vLLM, LocalAI ir LM Studio, /api Open WebUI. Torqvoice prideda /models ir /chat/completions.",
+        "apiKey": "API raktas",
+        "apiKeyHelp": "Tik jei serveris jo reikalauja. Ollama ir vLLM paprastai veikia be rakto; Open WebUI jį išduoda skiltyje Nustatymai, Paskyra."
+      },
+      "settings": {
+        "model": "Modelis",
+        "modelHelp": "Kuris modelis atsako. Sąraše yra viskas, ką serveris praneša, pagal pavadinimą. Jei norite, kad būtų skaitomos nuotraukos, naudokite modelį su vaizdų atpažinimu."
+      }
+    },
     "stripe": {
       "description": "Mokėjimai kortele iš sąskaitos nuorodos į jūsų Stripe paskyrą. Klientas sumoka, Stripe praneša Torqvoice, ir sąskaita pati pažymima kaip apmokėta.",
       "fields": {

+ 13 - 0
messages/nb/integrations.json

@@ -479,6 +479,19 @@
         "modelHelp": "Hvilken modell som svarer. Listen leses fra kontoen din, nyeste først. Mindre modeller koster mindre per kall og holder for det meste av verkstedteksten."
       }
     },
+    "openai-compatible": {
+      "description": "Servicebeskrivelser, historikksammendrag, dokumentlesing og assistenten, besvart av en hvilken som helst server som snakker OpenAIs chat-API: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM og lignende. Dataene dine blir på din egen maskinvare.",
+      "fields": {
+        "baseUrl": "Base-URL",
+        "baseUrlHelp": "Der serverens OpenAI-lignende API starter, inkludert stien: /v1 for Ollama, vLLM, LocalAI og LM Studio, /api for Open WebUI. Torqvoice legger til /models og /chat/completions.",
+        "apiKey": "API-nøkkel",
+        "apiKeyHelp": "Bare hvis serveren krever en. Ollama og vLLM kjører vanligvis uten nøkkel; Open WebUI utsteder en under Innstillinger, Konto."
+      },
+      "settings": {
+        "model": "Modell",
+        "modelHelp": "Hvilken modell som svarer. Listen er alt serveren rapporterer, etter navn. Bruk en modell med bildeforståelse hvis du vil at bilder skal leses."
+      }
+    },
     "stripe": {
       "description": "Kortbetaling fra fakturalenken, rett inn på din egen Stripe-konto. Kunden betaler, Stripe sier fra til Torqvoice, og fakturaen merkes betalt av seg selv.",
       "fields": {

+ 13 - 0
messages/nl/integrations.json

@@ -479,6 +479,19 @@
         "modelHelp": "Welk model antwoordt. De lijst komt uit uw account, nieuwste eerst. Kleinere modellen kosten minder per aanroep en volstaan voor de meeste werkplaatsteksten."
       }
     },
+    "openai-compatible": {
+      "description": "Servicebeschrijvingen, historieksamenvattingen, documentherkenning en de assistent, beantwoord door elke server die de chat-API van OpenAI spreekt: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM en dergelijke. Uw gegevens blijven op uw eigen hardware.",
+      "fields": {
+        "baseUrl": "Basis-URL",
+        "baseUrlHelp": "Waar de OpenAI-achtige API van de server begint, inclusief het pad: /v1 bij Ollama, vLLM, LocalAI en LM Studio, /api bij Open WebUI. Torqvoice plakt er /models en /chat/completions achter.",
+        "apiKey": "API-sleutel",
+        "apiKeyHelp": "Alleen als de server erom vraagt. Ollama en vLLM draaien meestal zonder sleutel; Open WebUI geeft er een uit onder Instellingen, Account."
+      },
+      "settings": {
+        "model": "Model",
+        "modelHelp": "Welk model antwoordt. De lijst is alles wat de server meldt, op naam. Kies een model met beeldherkenning als foto's gelezen moeten worden."
+      }
+    },
     "stripe": {
       "description": "Kaartbetalingen via de factuurlink, op je eigen Stripe-account. De klant betaalt, Stripe meldt het aan Torqvoice en de factuur markeert zichzelf als betaald.",
       "fields": {

+ 13 - 0
messages/pl/integrations.json

@@ -479,6 +479,19 @@
         "modelHelp": "Który model odpowiada. Lista pochodzi z Twojego konta, najnowsze na górze. Mniejsze modele kosztują mniej za wywołanie i wystarczają do większości tekstów warsztatowych."
       }
     },
+    "openai-compatible": {
+      "description": "Opisy usług, podsumowania historii, odczyt dokumentów i asystent, obsługiwane przez dowolny serwer mówiący API czatu OpenAI: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM i podobne. Twoje dane zostają na Twoim sprzęcie.",
+      "fields": {
+        "baseUrl": "Adres bazowy",
+        "baseUrlHelp": "Gdzie zaczyna się API serwera w stylu OpenAI, łącznie ze ścieżką: /v1 dla Ollama, vLLM, LocalAI i LM Studio, /api dla Open WebUI. Torqvoice dokleja /models i /chat/completions.",
+        "apiKey": "Klucz API",
+        "apiKeyHelp": "Tylko jeśli serwer go wymaga. Ollama i vLLM zwykle działają bez klucza; Open WebUI wydaje go w Ustawienia, Konto."
+      },
+      "settings": {
+        "model": "Model",
+        "modelHelp": "Który model odpowiada. Lista to wszystko, co zgłasza serwer, według nazwy. Do odczytu zdjęć wybierz model z obsługą obrazów."
+      }
+    },
     "stripe": {
       "description": "Płatności kartą z linku do faktury, na Twoje własne konto Stripe. Klient płaci, Stripe informuje Torqvoice, a faktura sama oznacza się jako opłacona.",
       "fields": {

+ 13 - 0
messages/pt-BR/integrations.json

@@ -479,6 +479,19 @@
         "modelHelp": "Qual modelo responde. A lista vem da sua conta, os mais novos primeiro. Modelos menores custam menos por chamada e bastam para a maior parte dos textos da oficina."
       }
     },
+    "openai-compatible": {
+      "description": "Descrições de serviço, resumos de histórico, leitura de documentos e o assistente, respondidos por qualquer servidor que fale a API de chat da OpenAI: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM e afins. Seus dados ficam no seu próprio hardware.",
+      "fields": {
+        "baseUrl": "URL base",
+        "baseUrlHelp": "Onde começa a API no estilo OpenAI do servidor, incluindo o caminho: /v1 para Ollama, vLLM, LocalAI e LM Studio, /api para Open WebUI. O Torqvoice acrescenta /models e /chat/completions.",
+        "apiKey": "Chave de API",
+        "apiKeyHelp": "Só se o servidor pedir uma. Ollama e vLLM costumam rodar sem chave; o Open WebUI emite uma em Configurações, Conta."
+      },
+      "settings": {
+        "model": "Modelo",
+        "modelHelp": "Qual modelo responde. A lista é tudo o que o servidor informa, por nome. Use um modelo com visão se quiser que fotos sejam lidas."
+      }
+    },
     "stripe": {
       "description": "Pagamentos com cartão pelo link da fatura, na sua própria conta Stripe. O cliente paga, o Stripe avisa a Torqvoice e a fatura se marca como paga sozinha.",
       "fields": {

+ 13 - 0
messages/ru/integrations.json

@@ -479,6 +479,19 @@
         "modelHelp": "Какая модель отвечает. Список берётся из вашего аккаунта, новые сверху. Модели поменьше дешевле за запрос и справляются с большинством текстов мастерской."
       }
     },
+    "openai-compatible": {
+      "description": "Описания работ, сводки истории, распознавание документов и ассистент, которые отвечает любой сервер с chat API OpenAI: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM и подобные. Ваши данные остаются на вашем оборудовании.",
+      "fields": {
+        "baseUrl": "Базовый URL",
+        "baseUrlHelp": "Где начинается OpenAI-совместимый API сервера, включая путь: /v1 для Ollama, vLLM, LocalAI и LM Studio, /api для Open WebUI. Torqvoice добавляет к нему /models и /chat/completions.",
+        "apiKey": "API-ключ",
+        "apiKeyHelp": "Только если сервер его требует. Ollama и vLLM обычно работают без ключа; Open WebUI выдаёт его в разделе Настройки, Аккаунт."
+      },
+      "settings": {
+        "model": "Модель",
+        "modelHelp": "Какая модель отвечает. В списке всё, что сообщает сервер, по названию. Для чтения фотографий выберите модель с поддержкой изображений."
+      }
+    },
     "stripe": {
       "description": "Оплата картой по ссылке на счёт на ваш собственный аккаунт Stripe. Клиент платит, Stripe сообщает Torqvoice, и счёт сам отмечается оплаченным.",
       "fields": {

+ 13 - 0
messages/tr/integrations.json

@@ -479,6 +479,19 @@
         "modelHelp": "Hangi modelin yanıtlayacağı. Liste hesabınızdan okunur, en yenisi üstte. Küçük modeller çağrı başına daha ucuzdur ve servis metinlerinin çoğuna yeter."
       }
     },
+    "openai-compatible": {
+      "description": "Servis açıklamaları, geçmiş özetleri, belge okuma ve asistan, OpenAI sohbet API'sini konuşan herhangi bir sunucu tarafından yanıtlanır: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM ve benzerleri. Verileriniz kendi donanımınızda kalır.",
+      "fields": {
+        "baseUrl": "Temel URL",
+        "baseUrlHelp": "Sunucunun OpenAI tarzı API'sinin başladığı yer, yol dahil: Ollama, vLLM, LocalAI ve LM Studio için /v1, Open WebUI için /api. Torqvoice buna /models ve /chat/completions ekler.",
+        "apiKey": "API anahtarı",
+        "apiKeyHelp": "Yalnızca sunucu isterse. Ollama ve vLLM genellikle anahtarsız çalışır; Open WebUI anahtarı Ayarlar, Hesap altında verir."
+      },
+      "settings": {
+        "model": "Model",
+        "modelHelp": "Hangi modelin yanıtlayacağı. Liste, sunucunun bildirdiği her şeydir, ada göre. Fotoğrafların okunmasını istiyorsanız görüntü destekli bir model kullanın."
+      }
+    },
     "stripe": {
       "description": "Fatura bağlantısından kendi Stripe hesabınıza kart ödemesi. Müşteri öder, Stripe Torqvoice'a bildirir ve fatura kendini ödendi olarak işaretler.",
       "fields": {

+ 1 - 0
public/images/integrations/openai-compatible.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="OpenAI-compatible"><title>OpenAI-compatible</title><rect width="48" height="48" rx="11" fill="#1f2937"/><g fill="none" stroke="#fff" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"><rect x="12" y="12" width="24" height="9" rx="2.5"/><rect x="12" y="27" width="24" height="9" rx="2.5"/><path d="M17 16.5h.01M17 31.5h.01"/><path d="M28 16.5h4M28 31.5h4"/></g></svg>

+ 43 - 2
src/__tests__/features/integrations/ai.test.ts

@@ -68,6 +68,43 @@ describe('AI connection', () => {
     expect(integrationConnection.create).not.toHaveBeenCalled()
   })
 
+  /**
+   * A self-hosted server is addressed by its URL, and often has no key at
+   * all, so the URL is what the setup must carry and the key is what it may
+   * do without.
+   */
+  it('runs on an OpenAI-compatible server by its base URL, key or no key', async () => {
+    integrationConnection.findMany.mockResolvedValue([
+      {
+        id: 'conn-local',
+        connectorId: 'openai-compatible',
+        credentials: sealCredentials({ baseUrl: 'http://ollama:11434/v1' }),
+        settings: { model: 'qwen3:8b' },
+      },
+    ])
+
+    await expect(aiSetup(ORG)).resolves.toEqual({
+      connectionId: 'conn-local',
+      provider: 'openai-compatible',
+      apiKey: '',
+      model: 'qwen3:8b',
+      baseUrl: 'http://ollama:11434/v1',
+    })
+  })
+
+  it('refuses an OpenAI-compatible connection without a base URL', async () => {
+    integrationConnection.findMany.mockResolvedValue([
+      {
+        id: 'conn-local',
+        connectorId: 'openai-compatible',
+        credentials: sealCredentials({ apiKey: 'sk-local' }),
+        settings: { model: 'qwen3:8b' },
+      },
+    ])
+
+    await expect(aiSetup(ORG)).resolves.toBeNull()
+  })
+
   it('adopts an old settings-page setup on first use, sealing the key', async () => {
     appSetting.findMany.mockResolvedValue(legacyRows())
 
@@ -131,7 +168,11 @@ describe('AI connection', () => {
     await aiSetup(ORG)
 
     expect(integrationConnection.updateMany).toHaveBeenCalledWith({
-      where: { organizationId: ORG, connectorId: { in: ['openai'] }, status: 'active' },
+      where: {
+        organizationId: ORG,
+        connectorId: { in: ['openai', 'openai-compatible'] },
+        status: 'active',
+      },
       data: { status: 'disconnected', lastError: null },
     })
   })
@@ -181,7 +222,7 @@ describe('plan gates', () => {
       .filter((m) => connectorAllowed(m, free))
       .map((m) => m.id)
       .sort()
-    expect(reachable).toEqual(['anthropic', 'openai'])
+    expect(reachable).toEqual(['anthropic', 'openai', 'openai-compatible'])
 
     const pro = PLAN_FEATURES.pro
     expect(connectorAllowed(getManifest('openai')!, pro)).toBe(true)

+ 119 - 0
src/__tests__/features/integrations/openai-compatible.test.ts

@@ -0,0 +1,119 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { ConnectorContext } from '@/features/integrations/Lib/types'
+import {
+  listCompatibleModels,
+  listOpenAiModels,
+  normalizeBaseUrl,
+  openAiHeaders,
+} from '@/integrations/ai/models'
+import { connector } from '@/integrations/openai-compatible/server'
+
+vi.mock('@/lib/db', () => ({ db: {} }))
+
+/** What Ollama lists next to what OpenAI does: none of the local names carry a vendor prefix. */
+const LOCAL_MODELS = ['qwen3:8b', 'llama3.1:8b', 'gemma3:12b', 'nomic-embed-text']
+const OPENAI_MODELS = ['gpt-4.1-mini', 'text-embedding-3-small', 'o4-mini', 'whisper-1']
+
+function context(
+  fetchImpl: (url: string, init?: RequestInit) => Promise<Response>,
+  credentials: Record<string, unknown> = {}
+): ConnectorContext {
+  const fetch = vi.fn(fetchImpl)
+  const http = {
+    fetch,
+    async json<T>(url: string, init?: RequestInit): Promise<T> {
+      const res = await fetch(url, init)
+      return (await res.json()) as T
+    },
+  }
+  return {
+    connection: {
+      id: 'c1',
+      organizationId: 'org',
+      connectorId: 'openai-compatible',
+      settings: {},
+      state: {},
+      externalAccountId: null,
+    },
+    credentials,
+    http,
+    links: { get: vi.fn(), set: vi.fn(), remove: vi.fn(), remoteIds: vi.fn(), byRemoteId: vi.fn() },
+    log: vi.fn(async () => undefined),
+    saveState: vi.fn(),
+    timezone: 'Europe/Oslo',
+    appUrl: 'https://app.test',
+  }
+}
+
+const listing = (ids: string[]) => async () =>
+  new Response(JSON.stringify({ data: ids.map((id) => ({ id })) }), { status: 200 })
+
+describe('OpenAI-compatible connector', () => {
+  it('lists every model the server reports, by its own name, in one order', async () => {
+    const ctx = context(listing(LOCAL_MODELS), { baseUrl: 'http://ollama:11434/v1/' })
+
+    await expect(listCompatibleModels(ctx)).resolves.toEqual([
+      { value: 'gemma3:12b', label: 'gemma3:12b' },
+      { value: 'llama3.1:8b', label: 'llama3.1:8b' },
+      { value: 'nomic-embed-text', label: 'nomic-embed-text' },
+      { value: 'qwen3:8b', label: 'qwen3:8b' },
+    ])
+    // The trailing slash the form may carry does not double up in the path.
+    expect(ctx.http.fetch).toHaveBeenCalledWith(
+      'http://ollama:11434/v1/models',
+      expect.objectContaining({ headers: {} })
+    )
+  })
+
+  it('still filters OpenAI itself down to its chat models', async () => {
+    const ctx = context(listing(OPENAI_MODELS), { apiKey: 'sk-1' })
+
+    const ids = (await listOpenAiModels(ctx)).map((m) => m.value)
+    expect(ids).toEqual(['o4-mini', 'gpt-4.1-mini'])
+  })
+
+  it('sends a bearer token only when there is a key', () => {
+    expect(openAiHeaders('sk-1')).toEqual({ Authorization: 'Bearer sk-1' })
+    expect(openAiHeaders('')).toEqual({})
+    expect(normalizeBaseUrl(' http://open-webui:8080/api// ')).toBe('http://open-webui:8080/api')
+  })
+
+  it('passes the check on any 2xx from /models, key or no key', async () => {
+    const ctx = context(listing(LOCAL_MODELS), { baseUrl: 'http://open-webui:8080/api' })
+    await expect(connector.test(ctx)).resolves.toEqual({ ok: true })
+    expect(ctx.http.fetch).toHaveBeenCalledWith(
+      'http://open-webui:8080/api/models',
+      expect.anything()
+    )
+  })
+
+  it('says which of the URL and the key is wrong', async () => {
+    const answer = (status: number) => async () => new Response('', { status })
+
+    await expect(
+      connector.test(context(answer(401), { baseUrl: 'http://x/v1', apiKey: 'bad' }))
+    ).resolves.toMatchObject({ ok: false, message: 'The server rejected the API key' })
+    await expect(
+      connector.test(context(answer(401), { baseUrl: 'http://x/v1' }))
+    ).resolves.toMatchObject({ ok: false, message: 'The server wants an API key' })
+    await expect(
+      connector.test(context(answer(404), { baseUrl: 'http://x:8080' }))
+    ).resolves.toMatchObject({ ok: false, message: expect.stringContaining('/models') })
+    await expect(
+      connector.test(context(answer(200), { baseUrl: 'ollama:11434/v1' }))
+    ).resolves.toMatchObject({ ok: false, message: expect.stringContaining('http://') })
+    await expect(
+      connector.test(
+        context(
+          async () => {
+            throw new Error('ECONNREFUSED')
+          },
+          { baseUrl: 'http://ollama:11434/v1' }
+        )
+      )
+    ).resolves.toMatchObject({
+      ok: false,
+      message: 'Could not reach ollama:11434. Check the base URL.',
+    })
+  })
+})

+ 26 - 1
src/__tests__/features/integrations/registry.test.ts

@@ -1,6 +1,6 @@
 import fs from 'node:fs'
 import path from 'node:path'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
 import {
   INSPECTION_CAPABILITY,
   INSPECTION_JOB,
@@ -103,6 +103,31 @@ describe('integration registry', () => {
     await expect(getConnector('nope')).rejects.toThrow()
   })
 
+  /**
+   * A connector that sends to an address the workshop types in exists on
+   * their own server and nowhere else. Leaving it out of the registry, not
+   * just the catalog, is what closes the settings page, the connect action
+   * and every job at once.
+   */
+  it('leaves self-hosted-only connectors out on the cloud instance', async () => {
+    const selfHostedOnly = listManifests().filter((m) => m.selfHostedOnly)
+    expect(selfHostedOnly.map((m) => m.id)).toContain('openai-compatible')
+
+    vi.stubEnv('TORQVOICE_MODE', 'cloud')
+    vi.resetModules()
+    try {
+      const cloud = await import('@/integrations/registry')
+      for (const m of selfHostedOnly) {
+        expect(cloud.getManifest(m.id), m.id).toBeNull()
+        await expect(cloud.getConnector(m.id), m.id).rejects.toThrow()
+      }
+      expect(cloud.listManifests().length).toBe(listManifests().length - selfHostedOnly.length)
+    } finally {
+      vi.unstubAllEnvs()
+      vi.resetModules()
+    }
+  })
+
   it('subscribes only to events the webhook dispatcher knows', async () => {
     const { WEBHOOK_EVENTS } = await import('@/features/webhooks/Schema/webhookSchema')
     const known = new Set<string>(WEBHOOK_EVENTS)

+ 45 - 0
src/__tests__/lib/ai-client.test.ts

@@ -0,0 +1,45 @@
+// @vitest-environment node
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/db', () => ({ db: {} }))
+
+import { createClient } from '@/lib/ai'
+
+/**
+ * Every provider gets its address spelled out. The SDK reads OPENAI_BASE_URL
+ * from the environment when none is given, which once sent completions to a
+ * server the connector's key check had never looked at.
+ */
+describe('createClient', () => {
+  it('addresses each provider explicitly, whatever the environment says', () => {
+    vi.stubEnv('OPENAI_BASE_URL', 'http://somewhere-else:9999/v1')
+    try {
+      expect(createClient({ provider: 'openai', apiKey: 'k', model: 'gpt-4o' }).baseURL).toBe(
+        'https://api.openai.com/v1'
+      )
+      expect(
+        createClient({ provider: 'anthropic', apiKey: 'k', model: 'claude-sonnet-4-6' }).baseURL
+      ).toBe('https://api.anthropic.com/v1/')
+      expect(
+        createClient({
+          provider: 'openai-compatible',
+          apiKey: '',
+          model: 'qwen3:8b',
+          baseUrl: 'http://open-webui:8080/api/',
+        }).baseURL
+      ).toBe('http://open-webui:8080/api')
+    } finally {
+      vi.unstubAllEnvs()
+    }
+  })
+
+  it('starts the SDK for a keyless server', () => {
+    const client = createClient({
+      provider: 'openai-compatible',
+      apiKey: '',
+      model: 'qwen3:8b',
+      baseUrl: 'http://ollama:11434/v1',
+    })
+    expect(client.apiKey).toBe('none')
+  })
+})

+ 9 - 0
src/__tests__/lib/ai-completion-tuning.test.ts

@@ -24,6 +24,15 @@ describe('completionTuning', () => {
     })
   })
 
+  it('keeps the classic parameters for an OpenAI-compatible server', () => {
+    // Ollama and LocalAI read max_tokens only; the model name says nothing
+    // about which vendor's rules apply.
+    expect(completionTuning(config('openai-compatible', 'o3-local'), 2000, 0.7)).toEqual({
+      max_tokens: 2000,
+      temperature: 0.7,
+    })
+  })
+
   it('sends max_completion_tokens for classic OpenAI models, keeping temperature', () => {
     expect(completionTuning(config('openai', 'gpt-4o'), 2000, 0.7)).toEqual({
       max_completion_tokens: 2000,

+ 13 - 2
src/features/integrations/Lib/ai.ts

@@ -18,7 +18,7 @@ import { AI_KEYS } from '@/features/ai/Schema/aiSettingsSchema'
 import { openCredentials, sealCredentials } from './vault'
 
 /** Connector ids that can answer a chat completion, most recently used first. */
-export const AI_CONNECTOR_IDS = ['openai', 'anthropic'] as const
+export const AI_CONNECTOR_IDS = ['openai', 'anthropic', 'openai-compatible'] as const
 export type AiConnectorId = (typeof AI_CONNECTOR_IDS)[number]
 
 export const AI_CHAT_CAPABILITY = 'ai.chat'
@@ -39,6 +39,8 @@ export interface AiSetup {
   provider: AiConnectorId
   apiKey: string
   model: string
+  /** The API root of an OpenAI-compatible server; absent for the vendors with a fixed one. */
+  baseUrl?: string
 }
 
 function isAiConnector(id: string): id is AiConnectorId {
@@ -224,15 +226,24 @@ export async function aiSetup(organizationId: string): Promise<AiSetup | null> {
   if (!row || !isAiConnector(row.connectorId)) return adoptLegacyAi(organizationId)
 
   let apiKey: string
+  let baseUrl: string
   try {
     const credentials = openCredentials(row.credentials)
     apiKey = typeof credentials.apiKey === 'string' ? credentials.apiKey : ''
+    baseUrl = typeof credentials.baseUrl === 'string' ? credentials.baseUrl.trim() : ''
   } catch (err) {
     return unsealedFallback(organizationId, row.id, err)
   }
 
   const model = modelOf(row.settings)
-  if (!apiKey || !model) return null
+  if (!model) return null
+  // A compatible server is addressed by its URL and may run without a key;
+  // the vendors are addressed by their key alone.
+  if (row.connectorId === 'openai-compatible') {
+    if (!baseUrl) return null
+    return { connectionId: row.id, provider: row.connectorId, apiKey, model, baseUrl }
+  }
+  if (!apiKey) return null
 
   return { connectionId: row.id, provider: row.connectorId, apiKey, model }
 }

+ 8 - 0
src/features/integrations/Lib/types.ts

@@ -126,6 +126,14 @@ export interface ConnectorManifest {
   schedules?: { job: string; everyMinutes: number }[]
   /** Plan feature that must be on, beyond the general integrations flag. */
   plan?: keyof PlanFeatures
+  /**
+   * Only offered on a self-hosted install. The registry leaves the connector
+   * out entirely on the cloud instance, so it is neither listed nor loadable
+   * there. For connectors that send to an address the workshop types in: on
+   * their own server that address is theirs to choose, on ours it would be
+   * a way to point the server at whatever else runs next to it.
+   */
+  selfHostedOnly?: boolean
 }
 
 export type LogLevel = 'info' | 'warn' | 'error'

+ 43 - 8
src/integrations/ai/models.ts

@@ -1,5 +1,5 @@
 /**
- * The model list behind both AI connectors.
+ * The model list behind the AI connectors.
  *
  * OpenAI and Anthropic each publish everything the key can reach, embeddings
  * and image models included, so the list is filtered to the chat models the
@@ -20,8 +20,23 @@ export function apiKeyOf(ctx: ConnectorContext): string {
   return typeof key === 'string' ? key.trim() : ''
 }
 
+/** Bearer auth, or nothing at all: a local server often has no key to check. */
 export function openAiHeaders(apiKey: string): Record<string, string> {
-  return { Authorization: `Bearer ${apiKey}` }
+  return apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
+}
+
+/**
+ * The API root of an OpenAI-compatible server as the connect form stored it,
+ * without a trailing slash so `${base}/models` lands where the vendor put it:
+ * Open WebUI serves under /api, most others under /v1.
+ */
+export function compatibleBaseUrlOf(ctx: ConnectorContext): string {
+  const url = ctx.credentials.baseUrl
+  return typeof url === 'string' ? normalizeBaseUrl(url) : ''
+}
+
+export function normalizeBaseUrl(url: string): string {
+  return url.trim().replace(/\/+$/, '')
 }
 
 export function anthropicHeaders(apiKey: string): Record<string, string> {
@@ -76,14 +91,34 @@ export function formatModelLabel(modelId: string): string {
     .replace(/Gpt/g, 'GPT')
 }
 
-export async function listOpenAiModels(ctx: ConnectorContext): Promise<SettingOption[]> {
-  const data = await ctx.http.json<{ data: { id: string }[] }>(`${OPENAI_BASE}/models`, {
+async function fetchModelIds(ctx: ConnectorContext, base: string): Promise<string[]> {
+  const data = await ctx.http.json<{ data?: { id?: unknown }[] }>(`${base}/models`, {
     headers: openAiHeaders(apiKeyOf(ctx)),
   })
-  return data.data
-    .filter((m) => isOpenAiChatModel(m.id))
-    .sort((a, b) => openAiModelOrder(a.id) - openAiModelOrder(b.id))
-    .map((m) => ({ value: m.id, label: formatModelLabel(m.id) }))
+  return (data?.data ?? [])
+    .map((m) => m?.id)
+    .filter((id): id is string => typeof id === 'string' && id.length > 0)
+}
+
+export async function listOpenAiModels(ctx: ConnectorContext): Promise<SettingOption[]> {
+  const ids = await fetchModelIds(ctx, OPENAI_BASE)
+  return ids
+    .filter(isOpenAiChatModel)
+    .sort((a, b) => openAiModelOrder(a) - openAiModelOrder(b))
+    .map((id) => ({ value: id, label: formatModelLabel(id) }))
+}
+
+/**
+ * Every model a compatible server lists, by its own name. The OpenAI filter
+ * would drop all of them (qwen3:8b, llama3.1:8b) and the label rewriting
+ * would mangle them, and a server that lists a model is offering it: what
+ * the model can do is for the workshop running it to know.
+ */
+export async function listCompatibleModels(ctx: ConnectorContext): Promise<SettingOption[]> {
+  const ids = await fetchModelIds(ctx, compatibleBaseUrlOf(ctx))
+  return [...new Set(ids)]
+    .sort((a, b) => a.localeCompare(b))
+    .map((id) => ({ value: id, label: id }))
 }
 
 /** Anthropic pages its model list, twenty at a time by default. */

+ 48 - 0
src/integrations/openai-compatible/manifest.ts

@@ -0,0 +1,48 @@
+import type { ConnectorManifest } from '@/features/integrations/Lib/types'
+
+/**
+ * Any server that speaks OpenAI's chat API as the workshop's AI provider:
+ * Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM and the gateways
+ * built on them. The point is a workshop keeping vehicle, customer and
+ * repair data on its own hardware, so the base URL is theirs to type and the
+ * model list is whatever that server offers, unfiltered.
+ *
+ * Self-hosted installs only. On the cloud instance a URL a workshop types in
+ * would be a way to make our server call whatever runs next to it, and no
+ * cloud workshop has asked for it.
+ */
+export const manifest: ConnectorManifest = {
+  id: 'openai-compatible',
+  name: 'OpenAI-compatible',
+  category: 'ai',
+  countries: 'global',
+  logo: '/images/integrations/openai-compatible.svg',
+  docs: '/docs/integrations/ai',
+  auth: {
+    type: 'api-key',
+    fields: [
+      {
+        key: 'baseUrl',
+        label: 'baseUrl',
+        type: 'url',
+        required: true,
+        placeholder: 'http://ollama:11434/v1',
+        help: 'baseUrlHelp',
+      },
+      { key: 'apiKey', label: 'apiKey', type: 'password', help: 'apiKeyHelp' },
+    ],
+  },
+  capabilities: ['ai.chat'],
+  settings: [
+    {
+      key: 'model',
+      type: 'remote-select',
+      label: 'model',
+      help: 'modelHelp',
+      source: 'models',
+      required: true,
+    },
+  ],
+  plan: 'ai',
+  selfHostedOnly: true,
+}

+ 51 - 0
src/integrations/openai-compatible/server.ts

@@ -0,0 +1,51 @@
+import type { ConnectorServer } from '@/features/integrations/Lib/types'
+import { apiKeyOf, compatibleBaseUrlOf, listCompatibleModels, openAiHeaders } from '../ai/models'
+import { manifest } from './manifest'
+
+/**
+ * The same proof OpenAI gets, against the workshop's own server: list the
+ * models. Here the URL is as likely to be wrong as the key, so the answers
+ * say which. A key is optional because Ollama and vLLM usually run without
+ * one; when the server does want one and none was given, it says 401 and the
+ * message points at the key.
+ */
+export const connector: ConnectorServer = {
+  manifest,
+
+  async test(ctx) {
+    const base = compatibleBaseUrlOf(ctx)
+    let host: string
+    try {
+      const url = new URL(base)
+      if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('scheme')
+      host = url.host
+    } catch {
+      return { ok: false, message: 'The base URL must start with http:// or https://' }
+    }
+
+    let res: Response
+    try {
+      res = await ctx.http.fetch(`${base}/models`, { headers: openAiHeaders(apiKeyOf(ctx)) })
+    } catch {
+      return { ok: false, message: `Could not reach ${host}. Check the base URL.` }
+    }
+    if (res.status === 401 || res.status === 403) {
+      return {
+        ok: false,
+        message: apiKeyOf(ctx) ? 'The server rejected the API key' : 'The server wants an API key',
+      }
+    }
+    if (res.status === 404) {
+      return {
+        ok: false,
+        message: `${host} has no /models under that path. The base URL should end where the vendor's API starts, such as /v1 or /api.`,
+      }
+    }
+    if (!res.ok) return { ok: false, message: `${host} answered HTTP ${res.status}` }
+    return { ok: true }
+  },
+
+  remoteOptions: { models: listCompatibleModels },
+
+  jobs: {},
+}

+ 19 - 1
src/integrations/registry.ts

@@ -15,6 +15,7 @@ import { manifest as mailgun } from './mailgun/manifest'
 import { manifest as microsoft365 } from './microsoft-365/manifest'
 import { manifest as nhtsa } from './nhtsa/manifest'
 import { manifest as openai } from './openai/manifest'
+import { manifest as openaiCompatible } from './openai-compatible/manifest'
 import { manifest as openapiAutomotive } from './openapi-automotive/manifest'
 import { manifest as paypal } from './paypal/manifest'
 import { manifest as postmark } from './postmark/manifest'
@@ -40,9 +41,10 @@ interface RegistryEntry {
   load: () => Promise<{ connector: ConnectorServer }>
 }
 
-const ENTRIES: readonly RegistryEntry[] = [
+const ALL_ENTRIES: readonly RegistryEntry[] = [
   { manifest: openai, load: () => import('./openai/server') },
   { manifest: anthropic, load: () => import('./anthropic/server') },
+  { manifest: openaiCompatible, load: () => import('./openai-compatible/server') },
   { manifest: googleCalendar, load: () => import('./google-calendar/server') },
   { manifest: microsoft365, load: () => import('./microsoft-365/server') },
   { manifest: zoom, load: () => import('./zoom/server') },
@@ -69,6 +71,22 @@ const ENTRIES: readonly RegistryEntry[] = [
   { manifest: quickbooks, load: () => import('./quickbooks/server') },
 ]
 
+/**
+ * The same test as isCloudMode() in lib/features, read here directly so the
+ * registry stays plain data with no database behind it.
+ */
+const IS_CLOUD = process.env.TORQVOICE_MODE === 'cloud'
+
+/**
+ * A self-hosted-only connector does not exist on the cloud instance: not in
+ * the catalog, not on its settings page, not for a job or a completion.
+ * Leaving it out here, rather than hiding the card, is what makes that one
+ * decision hold everywhere the id could arrive from.
+ */
+const ENTRIES: readonly RegistryEntry[] = ALL_ENTRIES.filter(
+  (e) => !(IS_CLOUD && e.manifest.selfHostedOnly)
+)
+
 const BY_ID = new Map(ENTRIES.map((e) => [e.manifest.id, e]))
 
 export function listManifests(): ConnectorManifest[] {

+ 33 - 6
src/lib/ai.ts

@@ -3,11 +3,19 @@ import { aiSetup } from '@/features/integrations/Lib/ai'
 import { localeNames, type Locale } from '@/i18n/config'
 import OpenAI from 'openai'
 import { describeAiError } from '@/lib/ai-error'
+import {
+  ANTHROPIC_BASE,
+  ANTHROPIC_VERSION,
+  OPENAI_BASE,
+  normalizeBaseUrl,
+} from '@/integrations/ai/models'
 
 interface AiConfig {
   provider: string
   apiKey: string
   model: string
+  /** API root of an OpenAI-compatible server, for the connector that takes one. */
+  baseUrl?: string
 }
 
 /**
@@ -19,23 +27,41 @@ export async function getAiConfig(organizationId: string): Promise<AiConfig> {
   const setup = await aiSetup(organizationId)
 
   if (!setup) {
-    throw new Error('AI is not connected. Connect OpenAI or Anthropic in Settings → Integrations.')
+    throw new Error('AI is not connected. Connect an AI provider in Settings → Integrations.')
   }
 
-  return { provider: setup.provider, apiKey: setup.apiKey, model: setup.model }
+  return {
+    provider: setup.provider,
+    apiKey: setup.apiKey,
+    model: setup.model,
+    ...(setup.baseUrl && { baseUrl: setup.baseUrl }),
+  }
 }
 
+/**
+ * Every provider gets its address spelled out. Left blank, the SDK would
+ * read OPENAI_BASE_URL from the environment on its own, and completions
+ * would quietly go somewhere the connector's key check never looked.
+ */
 export function createClient(config: AiConfig): OpenAI {
   if (config.provider === 'anthropic') {
     return new OpenAI({
       apiKey: config.apiKey,
-      baseURL: 'https://api.anthropic.com/v1/',
+      baseURL: `${ANTHROPIC_BASE}/`,
       defaultHeaders: {
-        'anthropic-version': '2023-06-01',
+        'anthropic-version': ANTHROPIC_VERSION,
       },
     })
   }
-  return new OpenAI({ apiKey: config.apiKey })
+  if (config.provider === 'openai-compatible') {
+    return new OpenAI({
+      // The SDK refuses to start without a key; a server that wants none
+      // ignores whatever the header says.
+      apiKey: config.apiKey || 'none',
+      baseURL: normalizeBaseUrl(config.baseUrl ?? ''),
+    })
+  }
+  return new OpenAI({ apiKey: config.apiKey, baseURL: OPENAI_BASE })
 }
 
 /**
@@ -44,7 +70,8 @@ export function createClient(config: AiConfig): OpenAI {
  * `max_completion_tokens`; they likewise refuse any temperature other than the
  * default. Every current OpenAI chat model accepts `max_completion_tokens`, so
  * it is used across the board there. The Anthropic compatibility endpoint
- * keeps the classic parameter.
+ * keeps the classic parameter, and so does a self-hosted OpenAI-compatible
+ * server: Ollama and LocalAI still only read `max_tokens`.
  *
  * Reasoning models spend billed-but-hidden thinking tokens inside the same
  * cap before producing a visible answer, so they get headroom on top of the