Skip to content

Commit 48e1592

Browse files
fix(zoo-gateway): stop unauthenticated model discovery 401 spam (#490)
* fix(zoo-gateway): stop unauthenticated model discovery 401 spam Skip /models when no session token, pass the profile zooSessionToken in requestRouterModels, and refetch zoo-gateway models when credentials become ready so signed-in users are not stuck on an empty list until re-login. * test(zoo-gateway): cover useZooGatewayRouterModelsSync hook Adds unit tests for the credential-driven model sync: skips when signed out, runs on zooGatewayCredentialsReady and on the sign-in transition, merges without clobbering other providers, and swallows fetch errors. Addresses CodeRabbit review feedback on PR #490. * test(zoo-gateway): assert full cache survives empty model response Strengthen the empty-response case to verify pre-existing providers (openrouter) are preserved, not just that zoo-gateway stays unset. Addresses CodeRabbit feedback on PR #490.
1 parent 8c9a79e commit 48e1592

12 files changed

Lines changed: 225 additions & 13 deletions

File tree

packages/types/src/vscode-extension-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export interface ExtensionMessage {
3838
| "commitSearchResults"
3939
| "listApiConfig"
4040
| "routerModels"
41+
| "zooGatewayCredentialsReady"
4142
| "openAiModels"
4243
| "ollamaModels"
4344
| "lmStudioModels"

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,11 @@ describe("Zoo Gateway Fetchers", () => {
7777
expect(models["anthropic/claude-sonnet-4"]).toBeDefined()
7878
})
7979

80-
it("omits the Authorization header when no token is provided", async () => {
81-
mockedAxios.get.mockResolvedValueOnce(mockResponse)
82-
83-
await getZooGatewayModels({ zooGatewayBaseUrl: baseUrl } as any)
80+
it("skips the request and returns {} when no token is available", async () => {
81+
const models = await getZooGatewayModels({ zooGatewayBaseUrl: baseUrl } as any)
8482

85-
const call = mockedAxios.get.mock.calls[0]
86-
expect(call[1].headers.Authorization).toBeUndefined()
83+
expect(mockedAxios.get).not.toHaveBeenCalled()
84+
expect(models).toEqual({})
8785
})
8886

8987
it("returns {} and never leaks the error object when the request fails", async () => {

src/api/providers/fetchers/zoo-gateway.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,12 @@ export async function getZooGatewayModels(options?: ApiHandlerOptions): Promise<
2525
const baseURL = options?.zooGatewayBaseUrl ?? `${getZooCodeBaseUrl()}/api/gateway/v1`
2626

2727
const sessionToken = resolveZooGatewaySessionToken(options?.zooSessionToken)
28-
const headers: Record<string, string> = {}
29-
if (sessionToken) {
30-
headers["Authorization"] = `Bearer ${sessionToken}`
28+
if (!sessionToken) {
29+
return models
30+
}
31+
32+
const headers: Record<string, string> = {
33+
Authorization: `Bearer ${sessionToken}`,
3134
}
3235

3336
try {

src/core/webview/ClineProvider.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -943,7 +943,8 @@ export class ClineProvider
943943
}
944944

945945
if (allUpToDate) {
946-
// All profiles have the current token — nothing to do
946+
const { postZooGatewayCredentialsReady } = await import("../../services/zoo-gateway-credentials-sync")
947+
postZooGatewayCredentialsReady((message) => this.postMessageToWebview(message))
947948
return
948949
}
949950
}
@@ -1815,6 +1816,8 @@ export class ClineProvider
18151816
)
18161817
}
18171818
await this.postStateToWebview()
1819+
const { postZooGatewayCredentialsReady } = await import("../../services/zoo-gateway-credentials-sync")
1820+
postZooGatewayCredentialsReady((message) => this.postMessageToWebview(message))
18181821
}
18191822

18201823
// Requesty

src/core/webview/__tests__/ClineProvider.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3707,12 +3707,14 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
37073707
} as any)
37083708
const upsertSpy = vi.spyOn(provider, "upsertProviderProfile").mockResolvedValue("profile-id")
37093709
vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined)
3710+
const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined)
37103711
;(provider as any).providerSettingsManager = {
37113712
listConfig: vi.fn().mockResolvedValue([]),
37123713
}
37133714

37143715
await provider.handleZooCodeCallback("zoo_ext_token")
37153716

3717+
expect(postMessageSpy).toHaveBeenCalledWith({ type: "zooGatewayCredentialsReady" })
37163718
expect(upsertSpy).toHaveBeenCalledWith(
37173719
"Zoo Gateway",
37183720
expect.objectContaining({
@@ -3809,6 +3811,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
38093811
const { getCachedZooCodeToken } = await import("../../../services/zoo-code-auth")
38103812
vi.mocked(getCachedZooCodeToken).mockReturnValue("current-token")
38113813
const handleSpy = vi.spyOn(provider, "handleZooCodeCallback").mockResolvedValue(undefined)
3814+
const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined)
38123815

38133816
;(provider as any).providerSettingsManager = {
38143817
listConfig: vi.fn().mockResolvedValue([{ name: "Zoo Gateway", apiProvider: "zoo-gateway" }]),
@@ -3821,6 +3824,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
38213824
await (provider as any).ensureZooGatewayProfileSeeded()
38223825

38233826
expect(handleSpy).not.toHaveBeenCalled()
3827+
expect(postMessageSpy).toHaveBeenCalledWith({ type: "zooGatewayCredentialsReady" })
38243828
})
38253829

38263830
it("re-seeds when any zoo-gateway profile has a stale or missing token", async () => {

src/core/webview/webviewMessageHandler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,7 @@ export const webviewMessageHandler = async (
981981
key: "zoo-gateway",
982982
options: {
983983
provider: "zoo-gateway",
984+
apiKey: apiConfiguration.zooSessionToken,
984985
baseUrl: apiConfiguration.zooGatewayBaseUrl,
985986
},
986987
},
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type { ExtensionMessage } from "@roo-code/types"
2+
3+
/** Notifies the webview that Zoo Gateway credentials are available for model discovery. */
4+
export function postZooGatewayCredentialsReady(postMessage: (message: ExtensionMessage) => void): void {
5+
postMessage({ type: "zooGatewayCredentialsReady" })
6+
}

webview-ui/src/components/settings/ApiOptions.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { vscode } from "@src/utils/vscode"
2525
import { validateApiConfigurationExcludingModelErrors, getModelValidationError } from "@src/utils/validate"
2626
import { useAppTranslation } from "@src/i18n/TranslationContext"
2727
import { useRouterModels } from "@src/components/ui/hooks/useRouterModels"
28+
import { useZooGatewayRouterModelsSync } from "@src/components/ui/hooks/useZooGatewayRouterModelsSync"
2829
import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel"
2930
import { requestLmStudioModels } from "@src/components/ui/hooks/useLmStudioModels"
3031
import { useExtensionState } from "@src/context/ExtensionStateContext"
@@ -171,6 +172,7 @@ const ApiOptions = ({
171172
typeof apiConfiguration.apiProvider === "string" && isRetiredProvider(apiConfiguration.apiProvider)
172173

173174
const { data: routerModels, refetch: refetchRouterModels } = useRouterModels()
175+
useZooGatewayRouterModelsSync()
174176

175177
const { data: openRouterModelProviders } = useOpenRouterModelProviders(
176178
apiConfiguration?.openRouterModelId,
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// npx vitest src/components/ui/hooks/__tests__/useZooGatewayRouterModelsSync.spec.ts
2+
3+
import React from "react"
4+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
5+
import { renderHook, waitFor } from "@testing-library/react"
6+
import type { Mock } from "vitest"
7+
8+
import type { ModelInfo, RouterModels } from "@roo-code/types"
9+
10+
import { useZooGatewayRouterModelsSync } from "../useZooGatewayRouterModelsSync"
11+
import { fetchRouterModels } from "../useRouterModels"
12+
import { useExtensionState } from "@src/context/ExtensionStateContext"
13+
14+
vi.mock("../useRouterModels")
15+
vi.mock("@src/context/ExtensionStateContext")
16+
17+
const mockFetchRouterModels = fetchRouterModels as Mock<typeof fetchRouterModels>
18+
const mockUseExtensionState = useExtensionState as unknown as Mock
19+
20+
const modelInfo: ModelInfo = {
21+
maxTokens: 8192,
22+
contextWindow: 200000,
23+
supportsImages: false,
24+
supportsPromptCache: false,
25+
}
26+
27+
const zooModels = { "anthropic/claude-sonnet-4": modelInfo }
28+
29+
// Test fixtures intentionally carry a single provider key; RouterModels requires
30+
// every provider key, so cast through unknown for these partial literals.
31+
const asRouterModels = (value: Record<string, Record<string, ModelInfo>>) => value as unknown as RouterModels
32+
33+
const setAuthenticated = (zooCodeIsAuthenticated: boolean) => {
34+
mockUseExtensionState.mockReturnValue({ zooCodeIsAuthenticated })
35+
}
36+
37+
const renderSyncHook = (queryClient: QueryClient) =>
38+
renderHook(() => useZooGatewayRouterModelsSync(), {
39+
wrapper: ({ children }: { children: React.ReactNode }) =>
40+
React.createElement(QueryClientProvider, { client: queryClient }, children),
41+
})
42+
43+
const makeQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } })
44+
45+
beforeEach(() => {
46+
vi.clearAllMocks()
47+
mockFetchRouterModels.mockResolvedValue(asRouterModels({ "zoo-gateway": zooModels }))
48+
})
49+
50+
describe("useZooGatewayRouterModelsSync", () => {
51+
it("does not fetch when the user is not authenticated", async () => {
52+
setAuthenticated(false)
53+
const queryClient = makeQueryClient()
54+
55+
renderSyncHook(queryClient)
56+
window.dispatchEvent(new MessageEvent("message", { data: { type: "zooGatewayCredentialsReady" } }))
57+
58+
await Promise.resolve()
59+
expect(mockFetchRouterModels).not.toHaveBeenCalled()
60+
})
61+
62+
it("fetches zoo-gateway models on the zooGatewayCredentialsReady message when authenticated", async () => {
63+
setAuthenticated(true)
64+
const queryClient = makeQueryClient()
65+
66+
renderSyncHook(queryClient)
67+
window.dispatchEvent(new MessageEvent("message", { data: { type: "zooGatewayCredentialsReady" } }))
68+
69+
await waitFor(() => expect(mockFetchRouterModels).toHaveBeenCalledWith("zoo-gateway"))
70+
})
71+
72+
it("fetches once on the false -> true authentication transition", async () => {
73+
setAuthenticated(false)
74+
const queryClient = makeQueryClient()
75+
76+
const { rerender } = renderSyncHook(queryClient)
77+
expect(mockFetchRouterModels).not.toHaveBeenCalled()
78+
79+
setAuthenticated(true)
80+
rerender()
81+
82+
await waitFor(() => expect(mockFetchRouterModels).toHaveBeenCalledTimes(1))
83+
})
84+
85+
it("merges into the routerModels cache without clobbering other providers", async () => {
86+
setAuthenticated(true)
87+
const queryClient = makeQueryClient()
88+
const existingOpenrouter = { "openai/gpt-4": modelInfo }
89+
queryClient.setQueryData(["routerModels", "all"], asRouterModels({ openrouter: existingOpenrouter }))
90+
91+
renderSyncHook(queryClient)
92+
window.dispatchEvent(new MessageEvent("message", { data: { type: "zooGatewayCredentialsReady" } }))
93+
94+
await waitFor(() => {
95+
const cached = queryClient.getQueryData<RouterModels>(["routerModels", "all"])
96+
expect(cached?.["zoo-gateway"]).toEqual(zooModels)
97+
expect(cached?.openrouter).toEqual(existingOpenrouter)
98+
})
99+
})
100+
101+
it("does not overwrite the cache when the fetch returns no zoo-gateway models", async () => {
102+
setAuthenticated(true)
103+
mockFetchRouterModels.mockResolvedValue(asRouterModels({ "zoo-gateway": {} }))
104+
const queryClient = makeQueryClient()
105+
queryClient.setQueryData(["routerModels", "all"], asRouterModels({ openrouter: { "openai/gpt-4": modelInfo } }))
106+
107+
renderSyncHook(queryClient)
108+
window.dispatchEvent(new MessageEvent("message", { data: { type: "zooGatewayCredentialsReady" } }))
109+
110+
await waitFor(() => expect(mockFetchRouterModels).toHaveBeenCalled())
111+
const cached = queryClient.getQueryData<RouterModels>(["routerModels", "all"])
112+
expect(cached?.["zoo-gateway"]).toBeUndefined()
113+
// The whole cache must survive an empty result, not just the zoo-gateway key.
114+
expect(cached?.openrouter).toEqual({ "openai/gpt-4": modelInfo })
115+
})
116+
117+
it("swallows fetch errors", async () => {
118+
setAuthenticated(true)
119+
mockFetchRouterModels.mockRejectedValue(new Error("router fetch in flight"))
120+
const queryClient = makeQueryClient()
121+
122+
renderSyncHook(queryClient)
123+
expect(() =>
124+
window.dispatchEvent(new MessageEvent("message", { data: { type: "zooGatewayCredentialsReady" } })),
125+
).not.toThrow()
126+
127+
await waitFor(() => expect(mockFetchRouterModels).toHaveBeenCalled())
128+
})
129+
})

webview-ui/src/components/ui/hooks/useRouterModels.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ type UseRouterModelsOptions = {
99
enabled?: boolean // gate fetching entirely
1010
}
1111

12-
const getRouterModels = async (provider?: string) =>
12+
export const fetchRouterModels = async (provider?: string) =>
1313
new Promise<RouterModels>((resolve, reject) => {
1414
const cleanup = () => {
1515
if (typeof window !== "undefined") {
@@ -57,7 +57,7 @@ export const useRouterModels = (opts: UseRouterModelsOptions = {}) => {
5757
const provider = opts.provider || undefined
5858
return useQuery({
5959
queryKey: ["routerModels", provider || "all"],
60-
queryFn: () => getRouterModels(provider),
60+
queryFn: () => fetchRouterModels(provider),
6161
enabled: opts.enabled !== false,
6262
})
6363
}

0 commit comments

Comments
 (0)