Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/i18n/locales/de/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -2010,6 +2013,7 @@
"provider": {
"anthropic": "Anthropic",
"mistral": "Mistral",
"modelrunner": "ModelRunner",
"openaiCompatible": "Selbst gehostet / lokaler Endpunkt",
"openrouter": "OpenRouter",
"managed": "Diese Instanz"
Expand Down
4 changes: 4 additions & 0 deletions app/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -2022,6 +2025,7 @@
"provider": {
"anthropic": "Anthropic",
"mistral": "Mistral",
"modelrunner": "ModelRunner",
"openaiCompatible": "Self-hosted / local endpoint",
"openrouter": "OpenRouter",
"managed": "This instance"
Expand Down
8 changes: 8 additions & 0 deletions app/routes/settings.ai.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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`.
Expand All @@ -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: '',
Expand Down
22 changes: 22 additions & 0 deletions app/services/vision/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
2 changes: 2 additions & 0 deletions app/services/vision/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 —
Expand Down
23 changes: 22 additions & 1 deletion app/services/vision/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 extends never> = T;
Expand Down
19 changes: 19 additions & 0 deletions tests/unit/vision-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/vision-provider-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ describe('getProvidersByPlacement', () => {
);
assert.deepStrictEqual(
getProvidersByPlacement('advanced').map((definition) => definition.id),
['openai-compatible', 'anthropic'],
['modelrunner', 'openai-compatible', 'anthropic'],
);
});
});
Expand Down
8 changes: 7 additions & 1 deletion types/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down