Skip to content

Commit fe0f4dc

Browse files
committed
fix(models): cap downgrade picks a vision-capable target for image turns
pickHardCapTarget chose the cheapest hard-cap target regardless of vision support, so an image-bearing turn that hit the per-user budget cap could be routed to a text-only model (Foundry DeepSeek) and fail the request. Gate the candidate set to vision-capable models when the turn carries an image, so an image turn downgrades to the cheapest model that can actually see it (Kimi before gpt-5.4-mini); text turns are unchanged. Falls back to no-downgrade if no vision-capable target is deployed. Note: gates on images attached to the CURRENT turn; images present only in earlier turns of a capped follow-up are not yet covered. Adds unit tests: text turn -> cheapest target; image turn -> cheapest vision target, never the text-only one.
1 parent b2e9898 commit fe0f4dc

2 files changed

Lines changed: 67 additions & 5 deletions

File tree

src/features/chat-page/chat-services/chat-api/__tests__/model-selection.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,49 @@ describe("resolveModelAndLimits — intent-based downgrade", () => {
228228
expect(result.selectedModel).toBe("gpt-5.4-mini");
229229
});
230230
});
231+
232+
describe("resolveModelAndLimits — cap downgrade respects vision capability", () => {
233+
// Cheapest hard-cap target first (DeepSeek, text-only) then a vision-capable
234+
// one (Kimi). A text turn should take the cheapest; an image turn must skip
235+
// the text-only model and take the cheapest VISION-capable target instead —
236+
// otherwise the image turn would be routed to a model that can't see it.
237+
const VISIONLESS = "DeepSeek-V4-Pro" as const;
238+
const VISION_CHEAP = "Kimi-K2.6" as const;
239+
let savedVisionless: string | undefined;
240+
let savedVision: string | undefined;
241+
242+
beforeEach(() => {
243+
savedVisionless = (MODEL_CONFIGS[VISIONLESS] as any).deploymentName;
244+
savedVision = (MODEL_CONFIGS[VISION_CHEAP] as any).deploymentName;
245+
(MODEL_CONFIGS[VISIONLESS] as any).deploymentName = "deepseek-test";
246+
(MODEL_CONFIGS[VISION_CHEAP] as any).deploymentName = "kimi-test";
247+
// Sanity: the fix relies on these real capability flags.
248+
expect(MODEL_CONFIGS[VISIONLESS].capabilities ?? []).not.toContain("vision");
249+
expect(MODEL_CONFIGS[VISION_CHEAP].capabilities ?? []).toContain("vision");
250+
mockCheckUserBudget.mockResolvedValue({ exceeded: true, window: "weekly", currentUsd: 9, limitUsd: 7 });
251+
mockGetDowngradeTargets.mockReturnValue({
252+
hardCapSet: [VISIONLESS, VISION_CHEAP],
253+
intentByClass: {},
254+
});
255+
});
256+
afterEach(() => {
257+
(MODEL_CONFIGS[VISIONLESS] as any).deploymentName = savedVisionless;
258+
(MODEL_CONFIGS[VISION_CHEAP] as any).deploymentName = savedVision;
259+
});
260+
261+
it("routes a TEXT-only capped turn to the cheapest target (text-only allowed)", async () => {
262+
const thread = makeThread({ selectedModel: "gpt-5.5" });
263+
const result = await resolveModelAndLimits({ selectedModel: "gpt-5.5" }, thread);
264+
expect(result.selectedModel).toBe(VISIONLESS);
265+
});
266+
267+
it("routes an IMAGE-bearing capped turn to the cheapest VISION target, NOT the text-only one", async () => {
268+
const thread = makeThread({ selectedModel: "gpt-5.5" });
269+
const result = await resolveModelAndLimits(
270+
{ selectedModel: "gpt-5.5", multimodalImages: ["data:image/png;base64,AAAA"] },
271+
thread,
272+
);
273+
expect(result.selectedModel).toBe(VISION_CHEAP);
274+
expect(result.selectedModel).not.toBe(VISIONLESS);
275+
});
276+
});

src/features/chat-page/chat-services/chat-api/model-selection.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,16 +77,28 @@ export interface ModelSelectionResult {
7777
* tools we prefer an eligible model that can host them (else the cheapest).
7878
* Returns undefined when no eligible+deployed target exists (caller fails safe).
7979
*/
80-
function pickHardCapTarget(opts: { preferResponsesAPI: boolean }): ChatModel | undefined {
80+
function pickHardCapTarget(opts: {
81+
preferResponsesAPI: boolean;
82+
requireVision: boolean;
83+
}): ChatModel | undefined {
8184
const { hardCapSet } = getDowngradeTargets();
82-
if (hardCapSet.length === 0) return undefined;
85+
// When the turn carries an image, only vision-capable targets are eligible.
86+
// The cheapest hard-cap target (Foundry DeepSeek) is text-only, so routing an
87+
// image turn there fails the request. Gating to vision-capable models means
88+
// an image turn downgrades to the cheapest model that can actually see it
89+
// (e.g. Kimi before gpt-5.4-mini). If none qualify we return undefined and
90+
// the caller fails safe (no downgrade) rather than break the turn.
91+
const candidates = opts.requireVision
92+
? hardCapSet.filter((id) => MODEL_CONFIGS[id].capabilities?.includes("vision"))
93+
: hardCapSet;
94+
if (candidates.length === 0) return undefined;
8395
if (opts.preferResponsesAPI) {
84-
const responsesCapable = hardCapSet.find(
96+
const responsesCapable = candidates.find(
8597
(id) => MODEL_CONFIGS[id].supportsResponsesAPI,
8698
);
8799
if (responsesCapable) return responsesCapable;
88100
}
89-
return hardCapSet[0];
101+
return candidates[0];
90102
}
91103

92104
export async function resolveModelAndLimits(
@@ -96,6 +108,9 @@ export async function resolveModelAndLimits(
96108
webSearchEnabled?: boolean;
97109
imageGenerationEnabled?: boolean;
98110
codeInterpreterEnabled?: boolean;
111+
/** Images attached to THIS turn — gates the cap downgrade to vision-capable
112+
* targets so an image turn never lands on a text-only model. */
113+
multimodalImages?: string[];
99114
},
100115
thread: ChatThreadModel
101116
): Promise<ModelSelectionResult> {
@@ -125,7 +140,8 @@ export async function resolveModelAndLimits(
125140
(payload.imageGenerationEnabled ?? thread.defaultTools?.imageGeneration) ||
126141
(payload.webSearchEnabled ?? thread.defaultTools?.webSearch)
127142
);
128-
const target = pickHardCapTarget({ preferResponsesAPI: wantsBuiltInTools });
143+
const requireVision = (payload.multimodalImages?.length ?? 0) > 0;
144+
const target = pickHardCapTarget({ preferResponsesAPI: wantsBuiltInTools, requireVision });
129145
const targetConfig = target ? MODEL_CONFIGS[target] : undefined;
130146
if (target && targetConfig?.deploymentName && target !== selectedModel) {
131147
fallbackInfo = {

0 commit comments

Comments
 (0)