From 101b4900da69007769ec981f09945f63303e9a6a Mon Sep 17 00:00:00 2001 From: James Mtendamema Date: Wed, 29 Jul 2026 03:04:30 -0600 Subject: [PATCH 1/4] fix(router-provider): fetch model metadata before context management decisions Router providers (zoo-gateway, kimi-code) that are auth-scoped skip the model cache entirely. On a fresh handler instance getModel() falls back to hardcoded defaults (e.g. 200k context window) because the real model list has not been fetched yet. Context management runs before createMessage() which is where fetchModel() normally happens, so condensing/truncation decisions use the wrong context window. Add ensureModelFetched() to RouterProvider that fetches once when the instance model map is empty. Call it in Task before context management so getModel() returns accurate metadata from the API. Co-authored-by: Cursor --- src/api/index.ts | 7 +++ .../providers/__tests__/zoo-gateway.spec.ts | 47 +++++++++++++++++++ src/api/providers/router-provider.ts | 6 +++ src/core/task/Task.ts | 2 + 4 files changed, 62 insertions(+) diff --git a/src/api/index.ts b/src/api/index.ts index c9992c9c39..f48ab50c0e 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -125,6 +125,13 @@ export interface ApiHandler { getModel(): { id: string; info: ModelInfo } + /** + * Ensures model metadata has been fetched from the remote API so that getModel() + * returns accurate info (context window, pricing, etc.) instead of hardcoded defaults. + * Only router providers that discover models over the network implement this. + */ + ensureModelFetched?(): Promise + /** * Optional context window for context-management / auto-condense when it must differ from * getModel().info.contextWindow. Only VS Code LM overrides it (static `maxInputTokens` vs its diff --git a/src/api/providers/__tests__/zoo-gateway.spec.ts b/src/api/providers/__tests__/zoo-gateway.spec.ts index e0c060db3b..82cffb5d1d 100644 --- a/src/api/providers/__tests__/zoo-gateway.spec.ts +++ b/src/api/providers/__tests__/zoo-gateway.spec.ts @@ -635,4 +635,51 @@ describe("ZooGatewayHandler", () => { ) }) }) + + describe("ensureModelFetched", () => { + it("fetches models when instance models are empty", async () => { + const handler = new ZooGatewayHandler(mockOptions) + const { getModels } = await import("../fetchers/modelCache") + + expect(handler.getModel().info.contextWindow).toBe(200000) + + await handler.ensureModelFetched() + + expect(getModels).toHaveBeenCalled() + }) + + it("skips the fetch when models are already populated", async () => { + const handler = new ZooGatewayHandler(mockOptions) + const { getModels } = await import("../fetchers/modelCache") + + await handler.ensureModelFetched() + vitest.mocked(getModels).mockClear() + + await handler.ensureModelFetched() + expect(getModels).not.toHaveBeenCalled() + }) + + it("makes getModel return the fetched context window instead of the default", async () => { + const { getModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockResolvedValueOnce({ + "google/gemini-2.5-pro": { + maxTokens: 65536, + contextWindow: 1048576, + supportsImages: true, + supportsPromptCache: false, + }, + }) + + const handler = new ZooGatewayHandler({ + ...mockOptions, + zooGatewayModelId: "google/gemini-2.5-pro", + }) + + expect(handler.getModel().info.contextWindow).toBe(200000) + + await handler.ensureModelFetched() + + expect(handler.getModel().info.contextWindow).toBe(1048576) + }) + }) }) diff --git a/src/api/providers/router-provider.ts b/src/api/providers/router-provider.ts index 7a983ed12e..baede32a17 100644 --- a/src/api/providers/router-provider.ts +++ b/src/api/providers/router-provider.ts @@ -61,6 +61,12 @@ export abstract class RouterProvider extends BaseProvider { return this.getModel() } + async ensureModelFetched(): Promise { + if (Object.keys(this.models).length === 0) { + await this.fetchModel() + } + } + override getModel(): { id: string; info: ModelInfo } { // Use `||` (not `??`) so an empty-string modelId also falls back to the default, // guaranteeing a non-empty id rather than forwarding "" to the API as an invalid diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 8f69a3a0d4..407235c1ca 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3842,6 +3842,7 @@ export class Task extends EventEmitter implements TaskLike { const { profileThresholds = {}, mode, apiConfiguration } = state ?? {} const { contextTokens } = this.getTokenUsage() + await this.api.ensureModelFetched?.() const modelInfo = this.api.getModel().info const maxTokens = getModelMaxOutputTokens({ @@ -4042,6 +4043,7 @@ export class Task extends EventEmitter implements TaskLike { const { contextTokens } = this.getTokenUsage() if (contextTokens) { + await this.api.ensureModelFetched?.() const modelInfo = this.api.getModel().info const maxTokens = getModelMaxOutputTokens({ From 47e073217ef9ea759e493bb53e13668ea885f99d Mon Sep 17 00:00:00 2001 From: James Mtendamema Date: Wed, 29 Jul 2026 03:21:29 -0600 Subject: [PATCH 2/4] fix(router-provider): single-flight ensureModelFetched and earlier call site Make ensureModelFetched single-flight so concurrent callers share a single in-flight fetch instead of firing duplicates. Move the call site before the cachedStreamingModel snapshot so the model info is accurate from the start of the streaming session, not just for context management. Co-authored-by: Cursor --- src/api/providers/__tests__/zoo-gateway.spec.ts | 10 ++++++++++ src/api/providers/router-provider.ts | 11 ++++++++++- src/core/task/Task.ts | 2 ++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/api/providers/__tests__/zoo-gateway.spec.ts b/src/api/providers/__tests__/zoo-gateway.spec.ts index 82cffb5d1d..d912dd0e04 100644 --- a/src/api/providers/__tests__/zoo-gateway.spec.ts +++ b/src/api/providers/__tests__/zoo-gateway.spec.ts @@ -659,6 +659,16 @@ describe("ZooGatewayHandler", () => { expect(getModels).not.toHaveBeenCalled() }) + it("deduplicates concurrent calls into a single fetch", async () => { + const handler = new ZooGatewayHandler(mockOptions) + const { getModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockClear() + + await Promise.all([handler.ensureModelFetched(), handler.ensureModelFetched()]) + + expect(getModels).toHaveBeenCalledTimes(1) + }) + it("makes getModel return the fetched context window instead of the default", async () => { const { getModels } = await import("../fetchers/modelCache") vitest.mocked(getModels).mockResolvedValueOnce({ diff --git a/src/api/providers/router-provider.ts b/src/api/providers/router-provider.ts index baede32a17..3d2ca6de3a 100644 --- a/src/api/providers/router-provider.ts +++ b/src/api/providers/router-provider.ts @@ -61,9 +61,18 @@ export abstract class RouterProvider extends BaseProvider { return this.getModel() } + private modelFetchPromise?: Promise + async ensureModelFetched(): Promise { if (Object.keys(this.models).length === 0) { - await this.fetchModel() + const fetchPromise = (this.modelFetchPromise ??= this.fetchModel().then(() => undefined)) + try { + await fetchPromise + } finally { + if (this.modelFetchPromise === fetchPromise) { + this.modelFetchPromise = undefined + } + } } } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 407235c1ca..e87b894ce4 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2762,6 +2762,8 @@ export class Task extends EventEmitter implements TaskLike { await this.diffViewProvider.reset() + await this.api.ensureModelFetched?.() + // Cache model info once per API request to avoid repeated calls during streaming // This is especially important for tools and background usage collection this.cachedStreamingModel = this.api.getModel() From 885d8e079cd720379617e9f6217c997e8c1ec32c Mon Sep 17 00:00:00 2001 From: James Mtendamema Date: Thu, 30 Jul 2026 05:07:04 -0600 Subject: [PATCH 3/4] fix(router-provider): address review feedback on fetch failures and double-fetch Make fetchModel single-flight and short-circuit once models are loaded so auth-scoped providers do not hit the models endpoint twice per request. Catch ensureModelFetched failures in Task via safeEnsureModelFetched so a metadata fetch error falls back to defaults instead of ending the task. Add reject-then-recover coverage and Task tests for the new call sites. Co-authored-by: Cursor --- .../providers/__tests__/zoo-gateway.spec.ts | 31 +++ src/api/providers/router-provider.ts | 37 +-- src/core/task/Task.ts | 22 +- src/core/task/__tests__/Task.spec.ts | 233 ++++++++++++++++++ 4 files changed, 306 insertions(+), 17 deletions(-) diff --git a/src/api/providers/__tests__/zoo-gateway.spec.ts b/src/api/providers/__tests__/zoo-gateway.spec.ts index d912dd0e04..e797dc9745 100644 --- a/src/api/providers/__tests__/zoo-gateway.spec.ts +++ b/src/api/providers/__tests__/zoo-gateway.spec.ts @@ -659,6 +659,17 @@ describe("ZooGatewayHandler", () => { expect(getModels).not.toHaveBeenCalled() }) + it("short-circuits a subsequent fetchModel call after models are populated", async () => { + const handler = new ZooGatewayHandler(mockOptions) + const { getModels } = await import("../fetchers/modelCache") + + await handler.ensureModelFetched() + vitest.mocked(getModels).mockClear() + + await handler.fetchModel() + expect(getModels).not.toHaveBeenCalled() + }) + it("deduplicates concurrent calls into a single fetch", async () => { const handler = new ZooGatewayHandler(mockOptions) const { getModels } = await import("../fetchers/modelCache") @@ -669,6 +680,26 @@ describe("ZooGatewayHandler", () => { expect(getModels).toHaveBeenCalledTimes(1) }) + it("recovers after a rejected fetch so later calls are not poisoned", async () => { + const handler = new ZooGatewayHandler(mockOptions) + const { getModels } = await import("../fetchers/modelCache") + + vitest.mocked(getModels).mockRejectedValueOnce(new Error("network down")) + await expect(handler.ensureModelFetched()).rejects.toThrow("network down") + + vitest.mocked(getModels).mockResolvedValueOnce({ + "anthropic/claude-sonnet-4": { + maxTokens: 64000, + contextWindow: 1000000, + supportsImages: true, + supportsPromptCache: true, + }, + }) + await handler.ensureModelFetched() + + expect(handler.getModel().info.contextWindow).toBe(1000000) + }) + it("makes getModel return the fetched context window instead of the default", async () => { const { getModels } = await import("../fetchers/modelCache") vitest.mocked(getModels).mockResolvedValueOnce({ diff --git a/src/api/providers/router-provider.ts b/src/api/providers/router-provider.ts index 3d2ca6de3a..cbdd49e58b 100644 --- a/src/api/providers/router-provider.ts +++ b/src/api/providers/router-provider.ts @@ -56,24 +56,33 @@ export abstract class RouterProvider extends BaseProvider { }) } - public async fetchModel() { - this.models = await getModels({ provider: this.name, apiKey: this.client.apiKey, baseUrl: this.client.baseURL }) - return this.getModel() - } + private modelFetchPromise?: Promise<{ id: string; info: ModelInfo }> - private modelFetchPromise?: Promise + public async fetchModel() { + if (Object.keys(this.models).length > 0) { + return this.getModel() + } - async ensureModelFetched(): Promise { - if (Object.keys(this.models).length === 0) { - const fetchPromise = (this.modelFetchPromise ??= this.fetchModel().then(() => undefined)) - try { - await fetchPromise - } finally { - if (this.modelFetchPromise === fetchPromise) { + if (!this.modelFetchPromise) { + this.modelFetchPromise = getModels({ + provider: this.name, + apiKey: this.client.apiKey, + baseUrl: this.client.baseURL, + }) + .then((models) => { + this.models = models + return this.getModel() + }) + .finally(() => { this.modelFetchPromise = undefined - } - } + }) } + + return this.modelFetchPromise + } + + async ensureModelFetched(): Promise { + await this.fetchModel() } override getModel(): { id: string; info: ModelInfo } { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index e87b894ce4..4ba2996c91 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2762,7 +2762,7 @@ export class Task extends EventEmitter implements TaskLike { await this.diffViewProvider.reset() - await this.api.ensureModelFetched?.() + await this.safeEnsureModelFetched() // Cache model info once per API request to avoid repeated calls during streaming // This is especially important for tools and background usage collection @@ -3839,12 +3839,28 @@ export class Task extends EventEmitter implements TaskLike { ) } + /** + * Ensures router-provider model metadata is loaded before getModel() is used for + * context management or streaming. Failures fall back to hardcoded defaults rather + * than aborting the task. + */ + private async safeEnsureModelFetched(): Promise { + try { + await this.api.ensureModelFetched?.() + } catch (error) { + console.error( + `[Task#${this.taskId}] Failed to fetch model metadata:`, + error instanceof Error ? error.message : error, + ) + } + } + private async handleContextWindowExceededError(): Promise { const state = await this.providerRef.deref()?.getState() const { profileThresholds = {}, mode, apiConfiguration } = state ?? {} const { contextTokens } = this.getTokenUsage() - await this.api.ensureModelFetched?.() + await this.safeEnsureModelFetched() const modelInfo = this.api.getModel().info const maxTokens = getModelMaxOutputTokens({ @@ -4045,7 +4061,7 @@ export class Task extends EventEmitter implements TaskLike { const { contextTokens } = this.getTokenUsage() if (contextTokens) { - await this.api.ensureModelFetched?.() + await this.safeEnsureModelFetched() const modelInfo = this.api.getModel().info const maxTokens = getModelMaxOutputTokens({ diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index d9f7240c5c..a1207d6604 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -32,6 +32,7 @@ type TaskTestAccess = { presentAssistantMessageSafe: () => void updateClineMessage: (message: import("@roo-code/types").ClineMessage) => Promise saveClineMessages: () => Promise + safeEnsureModelFetched: () => Promise } function getTaskTestAccess(task: Task): TaskTestAccess { @@ -185,6 +186,14 @@ vi.mock("../../environment/getEnvironmentDetails", () => ({ getEnvironmentDetails: vi.fn().mockResolvedValue(""), })) +vi.mock("../../mentions/processUserContentMentions", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + processUserContentMentions: vi.fn().mockImplementation(actual.processUserContentMentions), + } +}) + vi.mock("../../ignore/RooIgnoreController") vi.mock("../../../i18n", () => { @@ -2507,6 +2516,230 @@ describe("Cline", () => { }) }) + describe("safeEnsureModelFetched", () => { + it("loads model metadata before getModel is used", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const ensureModelFetched = vi.fn().mockResolvedValue(undefined) + Object.assign(task.api, { ensureModelFetched }) + + await getTaskTestAccess(task).safeEnsureModelFetched() + + expect(ensureModelFetched).toHaveBeenCalledTimes(1) + }) + + it("swallows fetch failures so callers can fall back to defaults", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const ensureModelFetched = vi.fn().mockRejectedValue(new Error("network down")) + Object.assign(task.api, { ensureModelFetched }) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(getTaskTestAccess(task).safeEnsureModelFetched()).resolves.toBeUndefined() + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to fetch model metadata"), + "network down", + ) + errorSpy.mockRestore() + }) + + it("is a no-op when the api handler does not implement ensureModelFetched", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + await expect(getTaskTestAccess(task).safeEnsureModelFetched()).resolves.toBeUndefined() + }) + + it("calls safeEnsureModelFetched from attemptApiRequest when context tokens are present", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + contextTokens: 50_000, + }) + const safeSpy = vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(undefined) + vi.spyOn(task.api, "getModel").mockReturnValue({ + id: mockApiConfig.apiModelId!, + info: { + supportsImages: false, + supportsPromptCache: true, + contextWindow: 200_000, + maxTokens: 4096, + } as ModelInfo, + }) + vi.spyOn(task.api, "createMessage").mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { type: "text", text: "ok" } + }, + async next() { + return { done: true, value: undefined } + }, + async return() { + return { done: true, value: undefined } + }, + async throw(error: unknown) { + throw error + }, + async [Symbol.asyncDispose]() {}, + } as AsyncGenerator) + + task.apiConversationHistory = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: "test message" }], + ts: Date.now(), + }, + ] + + const iterator = task.attemptApiRequest(0) + await iterator.next() + + expect(safeSpy).toHaveBeenCalled() + }) + + it("continues attemptApiRequest when model metadata fetch fails", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + contextTokens: 50_000, + }) + const ensureModelFetched = vi.fn().mockRejectedValue(new Error("fetch failed")) + Object.assign(task.api, { ensureModelFetched }) + vi.spyOn(task.api, "getModel").mockReturnValue({ + id: mockApiConfig.apiModelId!, + info: { + supportsImages: false, + supportsPromptCache: true, + contextWindow: 200_000, + maxTokens: 4096, + } as ModelInfo, + }) + vi.spyOn(task.api, "createMessage").mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { type: "text", text: "ok" } + }, + async next() { + return { done: false, value: { type: "text", text: "ok" } } + }, + async return() { + return { done: true, value: undefined } + }, + async throw(error: unknown) { + throw error + }, + async [Symbol.asyncDispose]() {}, + } as AsyncGenerator) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + task.apiConversationHistory = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: "test message" }], + ts: Date.now(), + }, + ] + + const iterator = task.attemptApiRequest(0) + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { type: "text", text: "ok" }, + }) + expect(errorSpy).toHaveBeenCalled() + errorSpy.mockRestore() + }) + + it("fetches model metadata before caching the streaming model", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const ensureModelFetched = vi.fn().mockResolvedValue(undefined) + Object.assign(task.api, { ensureModelFetched }) + vi.spyOn(task.api, "getModel").mockReturnValue({ + id: mockApiConfig.apiModelId!, + info: { + supportsImages: false, + supportsPromptCache: true, + contextWindow: 200_000, + maxTokens: 4096, + } as ModelInfo, + }) + vi.mocked(processUserContentMentions).mockResolvedValueOnce({ + content: [{ type: "text", text: "hello" }], + mode: undefined, + }) + const safeSpy = vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched") + vi.spyOn(task, "attemptApiRequest").mockImplementation(() => { + throw new Error("stop after model metadata fetch") + }) + vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined as never) + vi.spyOn(task, "addToApiConversationHistory").mockResolvedValue(undefined as never) + + task.clineMessages = [ + { + ts: Date.now(), + type: "say", + say: "api_req_started", + text: "{}", + }, + ] + vi.spyOn(task, "say").mockImplementation(async (type) => { + if (type === "api_req_started") { + task.clineMessages.push({ + ts: Date.now(), + type: "say", + say: "api_req_started", + text: "{}", + }) + } + return undefined as never + }) + + const result = await task.recursivelyMakeClineRequests([{ type: "text", text: "hello" }], false) + + expect(result).toBe(true) + expect(safeSpy).toHaveBeenCalled() + expect(ensureModelFetched).toHaveBeenCalled() + expect(task.cachedStreamingModel?.id).toBe(mockApiConfig.apiModelId) + }) + }) + describe("start()", () => { it("should be a no-op if the task was already started in the constructor", () => { const task = new Task({ From 126487473ac3fd0118d7a489a3331d47fb99596d Mon Sep 17 00:00:00 2001 From: James Mtendamema Date: Thu, 30 Jul 2026 05:39:30 -0600 Subject: [PATCH 4/4] fix(test): spy private addToApiConversationHistory via TaskTestAccess vi.spyOn on the private method fails check-types; route it through the existing test access cast like the other private helpers. Co-authored-by: Cursor --- src/core/task/__tests__/Task.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index a1207d6604..330241e221 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -33,6 +33,7 @@ type TaskTestAccess = { updateClineMessage: (message: import("@roo-code/types").ClineMessage) => Promise saveClineMessages: () => Promise safeEnsureModelFetched: () => Promise + addToApiConversationHistory: (message: unknown, reasoning?: string) => Promise } function getTaskTestAccess(task: Task): TaskTestAccess { @@ -2709,7 +2710,7 @@ describe("Cline", () => { }) vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined as never) - vi.spyOn(task, "addToApiConversationHistory").mockResolvedValue(undefined as never) + vi.spyOn(getTaskTestAccess(task), "addToApiConversationHistory").mockResolvedValue(undefined) task.clineMessages = [ {