From beff86ea05651a6d8252ab727b3214b10d229452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kaan=20Ak=C4=B1n?= Date: Wed, 9 Sep 2026 10:09:32 +0300 Subject: [PATCH] feat: add ModelRunner as a BYOK vision provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ModelRunner serves an OpenAI-compatible chat surface with CORS on it, so it rides the existing openai-compatible adapter unchanged: one registry entry, one catalog list, and the total maps the compiler demands. Behind Advanced rather than a third primary tab. The catalog is still populated, so the picker and the per-scan cost line both work — placement decides which panel it appears in, not whether it is curated. Exactly one model, because ModelRunner publishes exactly one chat model that accepts images; the rest of its chat catalog is text-only and would fail a plate scan on the missing input rather than return a worse answer. It is marked recommended only because the invariant requires one per non-empty catalog, and the blurb says as much — nothing here has been compared against real plates. The key check is /models, which is a real one: every other path under /v1 answers 404 without a key while that one answers 401, so a bad key cannot pass it. Guide copy is English and German; the German is mine and has not been read by a native speaker. Co-Authored-By: Claude Opus 5 --- app/i18n/locales/de/common.json | 4 ++++ app/i18n/locales/en/common.json | 4 ++++ app/routes/settings.ai.tsx | 8 +++++++ app/services/vision/catalog.ts | 22 ++++++++++++++++++++ app/services/vision/constants.ts | 2 ++ app/services/vision/registry.ts | 23 ++++++++++++++++++++- tests/unit/vision-catalog.test.ts | 19 +++++++++++++++++ tests/unit/vision-provider-registry.test.ts | 2 +- types/enums.ts | 8 ++++++- 9 files changed, 89 insertions(+), 3 deletions(-) diff --git a/app/i18n/locales/de/common.json b/app/i18n/locales/de/common.json index 4dd6856..fadb817 100644 --- a/app/i18n/locales/de/common.json +++ b/app/i18n/locales/de/common.json @@ -1906,6 +1906,7 @@ "settingsAi": { "advanced": { "anthropicOption": "Anthropic direkt", + "modelrunnerOption": "ModelRunner (ein Schlüssel, Modelle von mehreren Anbietern)", "backToRecommended": "← Doch lieber {{provider}} (empfohlen)", "openaiCompatibleOption": "Selbst gehosteter / lokaler Endpunkt (Ollama, vLLM, LM Studio ...), auf der gehosteten Instanz nur localhost", "toggle": "Erweitert: einen anderen Anbieter nutzen" @@ -1914,6 +1915,7 @@ "label": { "anthropic": "Anthropic API-Schlüssel", "mistral": "Mistral API-Schlüssel", + "modelrunner": "ModelRunner API-Schlüssel", "openaiCompatible": "API-Schlüssel (für deinen Endpunkt)", "openrouter": "OpenRouter API-Schlüssel" }, @@ -1974,6 +1976,7 @@ "google-gemini-3-1-flash-lite": "Günstig und fähig, eine hervorragende Standardwahl für Fotos von Mahlzeiten", "google-gemini-3-5-flash-lite": "Standard nach Verbindung mit OpenRouter, ausgewählt für Datenschutz, nicht Preis: kein Training mit deinen Fotos", "ministral-14b-2512": "Eine solide Einstiegsoption für kleines Budget, benennt Gerichte etwas ungelenk", + "alibaba-qwen3-8-max": "Das einzige Modell hier, das Bilder versteht, noch nicht an echten Tellern verglichen", "ministral-3b-2512": "Das günstigste Modell der Familie, für einfache, klar getrennte Speisen auf dem Teller", "ministral-8b-2512": "Sparvariante mit etwas mehr Luft nach oben", "mistral-large-2512": "Flaggschiff-Preis, aber die schwächste Aufschlüsselung der Familie", @@ -2010,6 +2013,7 @@ "provider": { "anthropic": "Anthropic", "mistral": "Mistral", + "modelrunner": "ModelRunner", "openaiCompatible": "Selbst gehostet / lokaler Endpunkt", "openrouter": "OpenRouter", "managed": "Diese Instanz" diff --git a/app/i18n/locales/en/common.json b/app/i18n/locales/en/common.json index c83e8c5..bc94bf1 100644 --- a/app/i18n/locales/en/common.json +++ b/app/i18n/locales/en/common.json @@ -1918,6 +1918,7 @@ "settingsAi": { "advanced": { "anthropicOption": "Anthropic direct", + "modelrunnerOption": "ModelRunner (one key, models from several labs)", "backToRecommended": "← Use {{provider}} instead (recommended)", "openaiCompatibleOption": "Self-hosted / local endpoint (Ollama, vLLM, LM Studio...), localhost only on the hosted instance", "toggle": "Advanced: use a different provider" @@ -1926,6 +1927,7 @@ "label": { "anthropic": "Anthropic API key", "mistral": "Mistral API key", + "modelrunner": "ModelRunner API key", "openaiCompatible": "API key (for your endpoint)", "openrouter": "OpenRouter API key" }, @@ -1986,6 +1988,7 @@ "google-gemini-3-1-flash-lite": "Cheap and capable, great default for plate photos", "google-gemini-3-5-flash-lite": "Default after connecting OpenRouter, picked for privacy, not price: no training on your photos", "ministral-14b-2512": "A respectable budget floor, names dishes a bit clumsily", + "alibaba-qwen3-8-max": "The only image-capable model here, not yet compared on real plates", "ministral-3b-2512": "Cheapest of the family, for simple, clearly separated plates", "ministral-8b-2512": "Budget tier with a little more headroom", "mistral-large-2512": "Flagship price, weakest plate breakdown of the family", @@ -2022,6 +2025,7 @@ "provider": { "anthropic": "Anthropic", "mistral": "Mistral", + "modelrunner": "ModelRunner", "openaiCompatible": "Self-hosted / local endpoint", "openrouter": "OpenRouter", "managed": "This instance" diff --git a/app/routes/settings.ai.tsx b/app/routes/settings.ai.tsx index 9006342..36bb878 100644 --- a/app/routes/settings.ai.tsx +++ b/app/routes/settings.ai.tsx @@ -140,6 +140,7 @@ const ADVANCED_PROVIDER_DEFINITIONS = getProvidersByPlacement('advanced'); */ const ADVANCED_OPTION_KEYS = { 'openai-compatible': 'settingsAi.advanced.openaiCompatibleOption', + modelrunner: 'settingsAi.advanced.modelrunnerOption', anthropic: 'settingsAi.advanced.anthropicOption', openrouter: undefined, mistral: undefined, @@ -165,6 +166,8 @@ const ADVANCED_OPTION_KEYS = { const PROVIDER_BLURB_KEYS = { openrouter: 'settingsAi.providerBlurb.openrouter', mistral: 'settingsAi.providerBlurb.mistral', + // No blurb, like the other providers behind "Advanced". + modelrunner: undefined, 'openai-compatible': undefined, anthropic: undefined, managed: undefined, @@ -176,6 +179,8 @@ const MODEL_PLACEHOLDER = { anthropic: 'claude-sonnet-5', openrouter: undefined, mistral: undefined, + // Curated catalog, so the picker renders and this is never used. + modelrunner: undefined, // NEVER RENDERED. `managed` has no tab, no card and no key field on this // page (`placement: 'derived'` in the registry) — its endpoint, model and // bearer all come from the open session. The entry exists because the map is @@ -227,6 +232,7 @@ export function modelBlurbKey(modelId: string): string { const API_KEY_LABEL_KEYS = { openrouter: 'settingsAi.apiKey.label.openrouter', mistral: 'settingsAi.apiKey.label.mistral', + modelrunner: 'settingsAi.apiKey.label.modelrunner', 'openai-compatible': 'settingsAi.apiKey.label.openaiCompatible', anthropic: 'settingsAi.apiKey.label.anthropic', // Never rendered — `managed` has no key field. See `ADVANCED_OPTION_KEYS`. @@ -243,6 +249,8 @@ const API_KEY_LABEL_KEYS = { const API_KEY_PLACEHOLDERS = { openrouter: 'sk-or-v1-...', mistral: '', + // 64 bare hex characters, no prefix — nothing to teach, same as Mistral. + modelrunner: '', 'openai-compatible': 'sk-...', anthropic: 'sk-ant-...', managed: '', diff --git a/app/services/vision/catalog.ts b/app/services/vision/catalog.ts index c4fa8bf..af65837 100644 --- a/app/services/vision/catalog.ts +++ b/app/services/vision/catalog.ts @@ -193,6 +193,28 @@ export const MODEL_CATALOG: ModelCatalog = { recommended: true, }, ], + /** + * ONE ENTRY, because ModelRunner publishes exactly one chat model that takes + * images (its `inputModalities` carry `image`); the rest of its chat catalog + * is text-only and would fail a plate scan on the missing input rather than + * return a worse answer. Priced from the platform's published per-million + * rates, verified against its public catalog on 2026-09-08. + * + * `recommended` is true because the catalog invariant requires exactly one + * recommendation per non-empty list — not because it won a comparison. The + * blurb says so; nothing here has been run against real plates. + */ + modelrunner: [ + { + id: 'alibaba/qwen3.8-max', + label: 'Qwen3.8-Max', + vendor: 'Alibaba', + blurb: 'The only image-capable model here — not yet compared on real plates', + inPerM: 2, + outPerM: 6, + recommended: true, + }, + ], // Empty on purpose — see the doc comment above. 'openai-compatible': [], managed: [], diff --git a/app/services/vision/constants.ts b/app/services/vision/constants.ts index 63da618..bc35f28 100644 --- a/app/services/vision/constants.ts +++ b/app/services/vision/constants.ts @@ -11,6 +11,7 @@ */ export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; +export const MODELRUNNER_BASE_URL = 'https://queue.modelrunner.run/v1'; /** * Where a user mints a key for each provider — the deep links the settings @@ -19,6 +20,7 @@ export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; export const OPENROUTER_KEYS_URL = 'https://openrouter.ai/settings/keys'; export const ANTHROPIC_KEYS_URL = 'https://console.anthropic.com/settings/keys'; export const MISTRAL_KEYS_URL = 'https://console.mistral.ai/api-keys'; +export const MODELRUNNER_KEYS_URL = 'https://modelrunner.ai/settings/api-keys'; /** * OpenRouter attribution headers (used for their app rankings/analytics — diff --git a/app/services/vision/registry.ts b/app/services/vision/registry.ts index f29ca26..1e63227 100644 --- a/app/services/vision/registry.ts +++ b/app/services/vision/registry.ts @@ -29,6 +29,8 @@ import type { AiProviderType } from '#types/enums'; import { ANTHROPIC_KEYS_URL, MISTRAL_KEYS_URL, + MODELRUNNER_BASE_URL, + MODELRUNNER_KEYS_URL, OPENROUTER_BASE_URL, OPENROUTER_KEYS_URL, getOpenrouterAttributionHeaders, @@ -154,6 +156,25 @@ export const PROVIDER_REGISTRY: ProviderRegistry = { placement: 'primary', keyConsoleUrl: MISTRAL_KEYS_URL, }, + modelrunner: { + id: 'modelrunner', + labelKey: 'settingsAi.provider.modelrunner', + authMethods: ['manual'], + // Fixed endpoint, reachable straight from the browser: `OPTIONS + // /v1/chat/completions` answers `access-control-allow-origin: *` with no + // `allow-credentials`, so BYOK needs no proxy and no Anthropic-style + // direct-browser-access opt-in — same shape as Mistral above + // (live-probed 2026-09-09). + baseUrl: MODELRUNNER_BASE_URL, + // A REAL key check, and the one place this differs from a guess: every + // other path under `/v1` answers 404 without a key (`/v1/nonsense`, + // `/v1/models/extra`), while `/v1/models` answers 401 — so the 401 is the + // route authenticating, not a catch-all, and a bad key cannot pass. + verification: { kind: 'base-url-path', path: '/models' }, + adapter: 'openai-compatible', + placement: 'advanced', + keyConsoleUrl: MODELRUNNER_KEYS_URL, + }, 'openai-compatible': { id: 'openai-compatible', labelKey: 'settingsAi.provider.openaiCompatible', @@ -221,7 +242,7 @@ export const PROVIDER_REGISTRY: ProviderRegistry = { // const`, and the `satisfies` constraint are a single guarantee, and the // milestone's verification greps for them together. // prettier-ignore -export const PROVIDER_IDS = ['openrouter', 'mistral', 'openai-compatible', 'anthropic', 'managed'] as const satisfies readonly AiProviderType[]; +export const PROVIDER_IDS = ['openrouter', 'mistral', 'modelrunner', 'openai-compatible', 'anthropic', 'managed'] as const satisfies readonly AiProviderType[]; /** Compiles only for `never` — the assertion vehicle for the check below. */ type AssertNever = T; diff --git a/tests/unit/vision-catalog.test.ts b/tests/unit/vision-catalog.test.ts index 3cf7e17..48908da 100644 --- a/tests/unit/vision-catalog.test.ts +++ b/tests/unit/vision-catalog.test.ts @@ -63,6 +63,25 @@ describe('MODEL_CATALOG integrity', () => { } }); + it('modelrunner ids are namespaced — the platform addresses owner/alias', () => { + for (const model of MODEL_CATALOG.modelrunner) { + assert.match(model.id, NAMESPACED_MODEL_ID_PATTERN, `"${model.id}" is not a well-formed owner/alias id`); + } + }); + + it('modelrunner lists only the one image-capable model, recommended by default', () => { + assert.deepStrictEqual( + MODEL_CATALOG.modelrunner.map((model) => model.id), + ['alibaba/qwen3.8-max'], + ); + const recommended = getRecommendedModel('modelrunner'); + assert.strictEqual(recommended?.id, 'alibaba/qwen3.8-max'); + assert.deepStrictEqual({ inPerM: recommended?.inPerM, outPerM: recommended?.outPerM }, { inPerM: 2, outPerM: 6 }); + // No reasoning flag: the platform documents no reasoning parameter, so + // there is nothing for the adapter to send. + assert.strictEqual(recommended?.disableReasoning, undefined); + }); + it('anthropic ids are bare — that is what the direct API takes', () => { for (const model of MODEL_CATALOG.anthropic) { assert.match(model.id, BARE_MODEL_ID_PATTERN, `"${model.id}" should carry no vendor namespace`); diff --git a/tests/unit/vision-provider-registry.test.ts b/tests/unit/vision-provider-registry.test.ts index 937c097..cdf6ce8 100644 --- a/tests/unit/vision-provider-registry.test.ts +++ b/tests/unit/vision-provider-registry.test.ts @@ -182,7 +182,7 @@ describe('getProvidersByPlacement', () => { ); assert.deepStrictEqual( getProvidersByPlacement('advanced').map((definition) => definition.id), - ['openai-compatible', 'anthropic'], + ['modelrunner', 'openai-compatible', 'anthropic'], ); }); }); diff --git a/types/enums.ts b/types/enums.ts index 4e0da43..eb7eaa5 100644 --- a/types/enums.ts +++ b/types/enums.ts @@ -30,7 +30,13 @@ export type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack'; * in the enum because `createVisionProvider` dispatches on it, and putting it * anywhere else would mean a second dispatch beside the registry. */ -export type AiProviderType = 'openrouter' | 'mistral' | 'openai-compatible' | 'anthropic' | 'managed'; +export type AiProviderType = + | 'openrouter' + | 'mistral' + | 'modelrunner' + | 'openai-compatible' + | 'anthropic' + | 'managed'; /** * Outcome of a single plate-identification provider call, recorded on every