From c4bb9a58a6ca710812cb97dfd8bcbc5fad3b3382 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:53:23 +0000 Subject: [PATCH] fix: never pick an image model as the default The default-model sort ranks "gemini-3-pro" highest, and that substring also matches gemini-3-pro-image-preview. On the redrob provider that image model became the default, so the first chat turn of every new session failed (the upstream answers 404 for a chat request to an image model). Redrob CAD hit this through ACP and had to pin redrob/auto itself. - Provider.sort ranks chat models first. A model is not a chat model when its capabilities say it outputs images or no text, or, for bare {id} rows like the ACP picker, when its id has an "image" segment. - On the redrob provider, `auto` (the console router, always seeded) is the default in both Provider.defaultModel and the ACP default. Tests: two sort cases (capability records and bare ids) and defaultModel on the redrob provider returns redrob/auto. --- packages/redrob/src/acp/service.ts | 4 ++- packages/redrob/src/provider/provider.ts | 23 ++++++++++++- .../redrob/test/provider/provider.test.ts | 33 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/redrob/src/acp/service.ts b/packages/redrob/src/acp/service.ts index 83064c8bd7..76568bdffb 100644 --- a/packages/redrob/src/acp/service.ts +++ b/packages/redrob/src/acp/service.ts @@ -793,7 +793,9 @@ function defaultModelFromConfig( // a default. Configured model, redrob provider, then sorted best model keep // the protocol response deterministic without extra session/message reads. const redrobProvider = providers[ProviderV2.ID.make("redrob")] - const redrobModel = redrobProvider ? Provider.sort(Object.values(redrobProvider.models))[0] : undefined + const redrobModel = redrobProvider + ? (redrobProvider.models[Provider.REDROB_DEFAULT_MODEL] ?? Provider.sort(Object.values(redrobProvider.models))[0]) + : undefined if (redrobProvider && redrobModel) return { providerID: redrobProvider.id, modelID: redrobModel.id } const best = Provider.sort(Object.values(providers).flatMap((provider) => Object.values(provider.models)))[0] diff --git a/packages/redrob/src/provider/provider.ts b/packages/redrob/src/provider/provider.ts index 9cd0d1f0b2..a9b9a8e013 100644 --- a/packages/redrob/src/provider/provider.ts +++ b/packages/redrob/src/provider/provider.ts @@ -2172,7 +2172,10 @@ const layer = Layer.effect( const configured = Object.keys(cfg.provider ?? {}) const provider = Object.values(s.providers).find((p) => configured.length === 0 || configured.includes(p.id)) if (!provider) return yield* new NoProvidersError() - const [model] = sort(Object.values(provider.models)) + const [model] = + provider.id === ProviderV2.ID.redrob && provider.models[REDROB_DEFAULT_MODEL] + ? [provider.models[REDROB_DEFAULT_MODEL]] + : sort(Object.values(provider.models)) if (!model) return yield* new NoModelsError({ providerID: provider.id }) return { providerID: provider.id, @@ -2186,9 +2189,27 @@ const layer = Layer.effect( const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] const smallModelFamilyPriority = ["gemini-flash", "gpt-nano", "claude-haiku"] +// An image-generation model answers a plain chat turn with an error, so it must never be picked as a +// default. "gemini-3-pro" in `priority` also matches "gemini-3-pro-image-preview", which made that +// model the default for the redrob provider and every first message failed with a 404. +// Capabilities are read when the caller passes full model records; bare {id} rows fall back to the id. +const IMAGE_MODEL_ID = /(^|[-/_.])image([-/_.]|$)/ +export function isChatModel(model: { + id: string + capabilities?: { output?: { text?: boolean; image?: boolean } } +}) { + const output = model.capabilities?.output + if (output?.text === false || output?.image === true) return false + return !IMAGE_MODEL_ID.test(model.id) +} + +// Redrob's own router. When the redrob provider lists it, it is the default. +export const REDROB_DEFAULT_MODEL = "auto" + export function sort(models: T[]) { return sortBy( models, + [(model) => (isChatModel(model) ? 0 : 1), "asc"], [(model) => priority.findIndex((filter) => model.id.includes(filter)), "desc"], [(model) => (model.id.includes("latest") ? 0 : 1), "asc"], [(model) => model.id, "desc"], diff --git a/packages/redrob/test/provider/provider.test.ts b/packages/redrob/test/provider/provider.test.ts index 4023b7bcea..dca3d8a558 100644 --- a/packages/redrob/test/provider/provider.test.ts +++ b/packages/redrob/test/provider/provider.test.ts @@ -378,6 +378,16 @@ it.instance( { config: { provider: {} } }, ) +it.instance( + "defaultModel picks redrob/auto when the redrob provider is the one in use", + Effect.gen(function* () { + const model = yield* Provider.use.defaultModel() + expect(String(model.providerID)).toBe("redrob") + expect(String(model.modelID)).toBe("auto") + }), + { config: { enabled_providers: ["redrob"] } }, +) + it.instance( "defaultModel returns a typed error when config excludes every provider", Effect.gen(function* () { @@ -846,6 +856,29 @@ test("provider.sort prioritizes preferred models", () => { expect(sorted[sorted.length - 1].id).not.toContain("sonnet-4") }) +test("provider.sort never puts an image model first", () => { + // The redrob catalog listed gemini-3-pro-image-preview; "gemini-3-pro" in the priority list + // matched it, so it became the default and every chat turn failed. + const models = [ + { id: "gemini-3-pro-image-preview", capabilities: { output: { text: true, image: true } } }, + { id: "gpt-image-1", capabilities: { output: { text: false, image: true } } }, + { id: "gemini-3-pro-preview", capabilities: { output: { text: true, image: false } } }, + { id: "some-chat-model", capabilities: { output: { text: true, image: false } } }, + ] as any[] + const sorted = Provider.sort(models) + expect(sorted[0].id).toBe("gemini-3-pro-preview") + expect(sorted.slice(-2).map((m) => m.id).sort()).toEqual(["gemini-3-pro-image-preview", "gpt-image-1"]) + + // Bare {id} rows (the ACP model picker) fall back to the id. + const bare = Provider.sort([{ id: "gemini-3-pro-image-preview" }, { id: "plain-model" }]) + expect(bare[0].id).toBe("plain-model") +}) + +test("isChatModel does not reject ids that merely contain 'image' inside a word", () => { + expect(Provider.isChatModel({ id: "imagenation-chat" })).toBe(true) + expect(Provider.isChatModel({ id: "flux/image" })).toBe(false) +}) + it.instance( "multiple providers can be configured simultaneously", Effect.gen(function* () {