Skip to content

Commit ca9b60f

Browse files
fix(router-provider): fetch model metadata before context management decisions (#1053)
* 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent eace967 commit ca9b60f

5 files changed

Lines changed: 375 additions & 2 deletions

File tree

src/api/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,13 @@ export interface ApiHandler {
125125

126126
getModel(): { id: string; info: ModelInfo }
127127

128+
/**
129+
* Ensures model metadata has been fetched from the remote API so that getModel()
130+
* returns accurate info (context window, pricing, etc.) instead of hardcoded defaults.
131+
* Only router providers that discover models over the network implement this.
132+
*/
133+
ensureModelFetched?(): Promise<void>
134+
128135
/**
129136
* Optional context window for context-management / auto-condense when it must differ from
130137
* getModel().info.contextWindow. Only VS Code LM overrides it (static `maxInputTokens` vs its

src/api/providers/__tests__/zoo-gateway.spec.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,4 +635,92 @@ describe("ZooGatewayHandler", () => {
635635
)
636636
})
637637
})
638+
639+
describe("ensureModelFetched", () => {
640+
it("fetches models when instance models are empty", async () => {
641+
const handler = new ZooGatewayHandler(mockOptions)
642+
const { getModels } = await import("../fetchers/modelCache")
643+
644+
expect(handler.getModel().info.contextWindow).toBe(200000)
645+
646+
await handler.ensureModelFetched()
647+
648+
expect(getModels).toHaveBeenCalled()
649+
})
650+
651+
it("skips the fetch when models are already populated", async () => {
652+
const handler = new ZooGatewayHandler(mockOptions)
653+
const { getModels } = await import("../fetchers/modelCache")
654+
655+
await handler.ensureModelFetched()
656+
vitest.mocked(getModels).mockClear()
657+
658+
await handler.ensureModelFetched()
659+
expect(getModels).not.toHaveBeenCalled()
660+
})
661+
662+
it("short-circuits a subsequent fetchModel call after models are populated", async () => {
663+
const handler = new ZooGatewayHandler(mockOptions)
664+
const { getModels } = await import("../fetchers/modelCache")
665+
666+
await handler.ensureModelFetched()
667+
vitest.mocked(getModels).mockClear()
668+
669+
await handler.fetchModel()
670+
expect(getModels).not.toHaveBeenCalled()
671+
})
672+
673+
it("deduplicates concurrent calls into a single fetch", async () => {
674+
const handler = new ZooGatewayHandler(mockOptions)
675+
const { getModels } = await import("../fetchers/modelCache")
676+
vitest.mocked(getModels).mockClear()
677+
678+
await Promise.all([handler.ensureModelFetched(), handler.ensureModelFetched()])
679+
680+
expect(getModels).toHaveBeenCalledTimes(1)
681+
})
682+
683+
it("recovers after a rejected fetch so later calls are not poisoned", async () => {
684+
const handler = new ZooGatewayHandler(mockOptions)
685+
const { getModels } = await import("../fetchers/modelCache")
686+
687+
vitest.mocked(getModels).mockRejectedValueOnce(new Error("network down"))
688+
await expect(handler.ensureModelFetched()).rejects.toThrow("network down")
689+
690+
vitest.mocked(getModels).mockResolvedValueOnce({
691+
"anthropic/claude-sonnet-4": {
692+
maxTokens: 64000,
693+
contextWindow: 1000000,
694+
supportsImages: true,
695+
supportsPromptCache: true,
696+
},
697+
})
698+
await handler.ensureModelFetched()
699+
700+
expect(handler.getModel().info.contextWindow).toBe(1000000)
701+
})
702+
703+
it("makes getModel return the fetched context window instead of the default", async () => {
704+
const { getModels } = await import("../fetchers/modelCache")
705+
vitest.mocked(getModels).mockResolvedValueOnce({
706+
"google/gemini-2.5-pro": {
707+
maxTokens: 65536,
708+
contextWindow: 1048576,
709+
supportsImages: true,
710+
supportsPromptCache: false,
711+
},
712+
})
713+
714+
const handler = new ZooGatewayHandler({
715+
...mockOptions,
716+
zooGatewayModelId: "google/gemini-2.5-pro",
717+
})
718+
719+
expect(handler.getModel().info.contextWindow).toBe(200000)
720+
721+
await handler.ensureModelFetched()
722+
723+
expect(handler.getModel().info.contextWindow).toBe(1048576)
724+
})
725+
})
638726
})

src/api/providers/router-provider.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,33 @@ export abstract class RouterProvider extends BaseProvider {
5656
})
5757
}
5858

59+
private modelFetchPromise?: Promise<{ id: string; info: ModelInfo }>
60+
5961
public async fetchModel() {
60-
this.models = await getModels({ provider: this.name, apiKey: this.client.apiKey, baseUrl: this.client.baseURL })
61-
return this.getModel()
62+
if (Object.keys(this.models).length > 0) {
63+
return this.getModel()
64+
}
65+
66+
if (!this.modelFetchPromise) {
67+
this.modelFetchPromise = getModels({
68+
provider: this.name,
69+
apiKey: this.client.apiKey,
70+
baseUrl: this.client.baseURL,
71+
})
72+
.then((models) => {
73+
this.models = models
74+
return this.getModel()
75+
})
76+
.finally(() => {
77+
this.modelFetchPromise = undefined
78+
})
79+
}
80+
81+
return this.modelFetchPromise
82+
}
83+
84+
async ensureModelFetched(): Promise<void> {
85+
await this.fetchModel()
6286
}
6387

6488
override getModel(): { id: string; info: ModelInfo } {

src/core/task/Task.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2762,6 +2762,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
27622762

27632763
await this.diffViewProvider.reset()
27642764

2765+
await this.safeEnsureModelFetched()
2766+
27652767
// Cache model info once per API request to avoid repeated calls during streaming
27662768
// This is especially important for tools and background usage collection
27672769
this.cachedStreamingModel = this.api.getModel()
@@ -3837,11 +3839,28 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
38373839
)
38383840
}
38393841

3842+
/**
3843+
* Ensures router-provider model metadata is loaded before getModel() is used for
3844+
* context management or streaming. Failures fall back to hardcoded defaults rather
3845+
* than aborting the task.
3846+
*/
3847+
private async safeEnsureModelFetched(): Promise<void> {
3848+
try {
3849+
await this.api.ensureModelFetched?.()
3850+
} catch (error) {
3851+
console.error(
3852+
`[Task#${this.taskId}] Failed to fetch model metadata:`,
3853+
error instanceof Error ? error.message : error,
3854+
)
3855+
}
3856+
}
3857+
38403858
private async handleContextWindowExceededError(): Promise<void> {
38413859
const state = await this.providerRef.deref()?.getState()
38423860
const { profileThresholds = {}, mode, apiConfiguration } = state ?? {}
38433861

38443862
const { contextTokens } = this.getTokenUsage()
3863+
await this.safeEnsureModelFetched()
38453864
const modelInfo = this.api.getModel().info
38463865

38473866
const maxTokens = getModelMaxOutputTokens({
@@ -4042,6 +4061,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
40424061
const { contextTokens } = this.getTokenUsage()
40434062

40444063
if (contextTokens) {
4064+
await this.safeEnsureModelFetched()
40454065
const modelInfo = this.api.getModel().info
40464066

40474067
const maxTokens = getModelMaxOutputTokens({

0 commit comments

Comments
 (0)