Skip to content

Commit bb94f9b

Browse files
James Mtendamemacursoragent
andcommitted
Address PR #229 review feedback.
Align observability retention with the website privacy policy (90-day metadata logs; plan-gated dashboard visibility). Harden zoo-gateway model fetching (timeout, safe error logging) and cache bypass in refreshModels. Fix sign-out to always clear the active in-memory profile, initialize zooGatewayModelId on provider switch, serialize multi-instance auth callbacks, and add regression tests. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 5feaee9 commit bb94f9b

11 files changed

Lines changed: 199 additions & 39 deletions

File tree

PRIVACY.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,14 @@ go—and, importantly, where they don't.
4444
Zoo Code, Zoo Code will send LLM usage telemetry to the Zoo Code backend
4545
(zoocode.dev). This includes task ID, AI provider name, model name, token
4646
counts (input/output/cache), and estimated cost. This data is linked to your
47-
authenticated Zoo Code account. Free plan users have their telemetry retained
48-
for 7 days; Pro and higher plan users have unlimited retention. You can stop
49-
this collection at any time by signing out via the Zoo Code badge in the chat
50-
area.
47+
authenticated Zoo Code account and is retained for up to 90 days as
48+
metadata-only API request logs, as described in the
49+
[zoocode.dev Privacy Policy](https://www.zoocode.dev/legal/privacy). Free
50+
plan users can view their telemetry in the dashboard for the most recent 7
51+
days; Pro and higher plan users can view the full 90-day window. You can
52+
stop this collection at any time by signing out via the Zoo Code badge in
53+
the chat area, and you may request deletion of your data at any time per
54+
the privacy policy.
5155
- **Marketplace Requests**: When you browse or search the Marketplace for Model
5256
Configuration Profiles (MCPs) or Custom Modes, Zoo Code makes a secure API
5357
call to Zoo Code's backend servers to retrieve listing information. These

src/activate/__tests__/handleUri.spec.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,55 @@ describe("handleUri", () => {
126126
expect(mockSetZooCodeUserInfo).not.toHaveBeenCalled()
127127
expect(mockVisibleProvider.handleZooCodeCallback).not.toHaveBeenCalled()
128128
})
129+
130+
it("propagates the callback token to every ClineProvider instance, not just the visible one", async () => {
131+
// Regression: prior to multi-instance fan-out, hidden providers (sidebar collapsed,
132+
// secondary panels) never received the zooSessionToken, so their profile settings
133+
// stayed unauthenticated until reload.
134+
mockHandleZooCodeAuthCallback.mockResolvedValue(true)
135+
136+
const hiddenProvider = { handleZooCodeCallback: vi.fn() } as any
137+
const secondHidden = { handleZooCodeCallback: vi.fn() } as any
138+
mockGetAllInstances.mockReturnValue([mockVisibleProvider, hiddenProvider, secondHidden])
139+
140+
await handleUri({
141+
path: "/auth-callback",
142+
query: "token=zoo_ext_test_token",
143+
} as any)
144+
145+
expect(mockHandleZooCodeAuthCallback).toHaveBeenCalledWith("zoo_ext_test_token")
146+
expect(mockSetZooCodeUserInfo).toHaveBeenCalled()
147+
expect(mockVisibleProvider.handleZooCodeCallback).toHaveBeenCalledWith("zoo_ext_test_token")
148+
expect(hiddenProvider.handleZooCodeCallback).toHaveBeenCalledWith("zoo_ext_test_token")
149+
expect(secondHidden.handleZooCodeCallback).toHaveBeenCalledWith("zoo_ext_test_token")
150+
})
151+
152+
it("serializes callbacks across instances to avoid concurrent profile-store writes", async () => {
153+
// Regression: a previous implementation used Promise.all which fanned out concurrent
154+
// read-modify-write operations on the same provider settings store. Verify the
155+
// callbacks are invoked sequentially.
156+
mockHandleZooCodeAuthCallback.mockResolvedValue(true)
157+
158+
const order: string[] = []
159+
const makeProvider = (name: string) =>
160+
({
161+
handleZooCodeCallback: vi.fn(async () => {
162+
order.push(`${name}:start`)
163+
// Yield to the event loop so a concurrent call would interleave.
164+
await new Promise((resolve) => setTimeout(resolve, 0))
165+
order.push(`${name}:end`)
166+
}),
167+
}) as any
168+
169+
const a = makeProvider("a")
170+
const b = makeProvider("b")
171+
mockGetAllInstances.mockReturnValue([a, b])
172+
173+
await handleUri({
174+
path: "/auth-callback",
175+
query: "token=zoo_ext_test_token",
176+
} as any)
177+
178+
expect(order).toEqual(["a:start", "a:end", "b:start", "b:end"])
179+
})
129180
})

src/activate/handleUri.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,17 @@ export const handleUri = async (uri: vscode.Uri) => {
5454
// The profile settings write (handleZooCodeCallback) must run on any active
5555
// instance — not just the visible one — so the zoo-gateway zooSessionToken
5656
// is persisted even when the sidebar/panel is hidden at callback time.
57+
//
58+
// Run sequentially (NOT Promise.all): each ClineProvider's
59+
// handleZooCodeCallback does a read-modify-write on the same backing
60+
// provider settings store (listConfig → getProfile → saveConfig /
61+
// upsertProviderProfile). Fanning out concurrently across N instances
62+
// can interleave reads/writes and clobber updates. Serialization here
63+
// is cheap (at most a handful of instances) and avoids the race.
5764
const allInstances = ClineProvider.getAllInstances()
58-
await Promise.all(allInstances.map((instance) => instance.handleZooCodeCallback(token)))
65+
for (const instance of allInstances) {
66+
await instance.handleZooCodeCallback(token)
67+
}
5968
}
6069
}
6170
break

src/api/providers/fetchers/modelCache.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,11 @@ export const getModels = async (options: GetModelsOptions): Promise<ModelRecord>
171171
export const refreshModels = async (options: GetModelsOptions): Promise<ModelRecord> => {
172172
const { provider } = options
173173

174+
// Zoo Gateway models are user-specific (auth-scoped). Mirror the bypass in
175+
// getModels() so we never persist one user's model list and serve it to a
176+
// different authenticated user from cache.
177+
const shouldSkipCache = provider === "zoo-gateway"
178+
174179
// Check if there's already an in-flight refresh for this provider
175180
// This prevents race conditions where multiple concurrent refreshes might
176181
// overwrite each other's results
@@ -187,7 +192,7 @@ export const refreshModels = async (options: GetModelsOptions): Promise<ModelRec
187192
const modelCount = Object.keys(models).length
188193

189194
// Get existing cached data for comparison
190-
const existingCache = getModelsFromCache(provider)
195+
const existingCache = shouldSkipCache ? undefined : getModelsFromCache(provider)
191196
const existingCount = existingCache ? Object.keys(existingCache).length : 0
192197

193198
if (modelCount === 0) {
@@ -204,18 +209,23 @@ export const refreshModels = async (options: GetModelsOptions): Promise<ModelRec
204209
}
205210
}
206211

207-
// Update memory cache first
208-
memoryCache.set(provider, models)
212+
if (!shouldSkipCache) {
213+
memoryCache.set(provider, models)
209214

210-
// Atomically write to disk (safeWriteJson handles atomic writes)
211-
await writeModels(provider, models).catch((err) =>
212-
console.error(`[refreshModels] Error writing ${provider} models to disk:`, err),
213-
)
215+
await writeModels(provider, models).catch((err) =>
216+
console.error(`[refreshModels] Error writing ${provider} models to disk:`, err),
217+
)
218+
}
214219

215220
return models
216221
} catch (error) {
217-
// Log the error for debugging, then return existing cache if available (graceful degradation)
222+
// Log the error for debugging, then return existing cache if available (graceful degradation).
223+
// For auth-scoped providers (zoo-gateway) we MUST NOT return cached models from a prior
224+
// session, since they could belong to a different user — return empty instead.
218225
console.error(`[refreshModels] Failed to refresh ${provider} models:`, error)
226+
if (shouldSkipCache) {
227+
return {}
228+
}
219229
return getModelsFromCache(provider) || {}
220230
} finally {
221231
// Always clean up the in-flight tracking

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ const zooGatewayModelsResponseSchema = z.object({
5050

5151
type ZooGatewayModelsResponse = z.infer<typeof zooGatewayModelsResponseSchema>
5252

53+
// Bound model discovery so a network stall can't hang provider initialization paths.
54+
const MODEL_DISCOVERY_TIMEOUT_MS = 15_000
55+
5356
/**
5457
* getZooGatewayModels
5558
*
@@ -69,6 +72,7 @@ export async function getZooGatewayModels(options?: ApiHandlerOptions): Promise<
6972
try {
7073
const response = await axios.get<ZooGatewayModelsResponse>(`${baseURL}/models`, {
7174
headers,
75+
timeout: MODEL_DISCOVERY_TIMEOUT_MS,
7276
})
7377
const result = zooGatewayModelsResponseSchema.safeParse(response.data)
7478
const data = result.success ? result.data.data : response.data.data
@@ -90,8 +94,16 @@ export async function getZooGatewayModels(options?: ApiHandlerOptions): Promise<
9094
models[id] = parseZooGatewayModel({ id, model: model as VercelAiGatewayModel })
9195
}
9296
} catch (error) {
97+
// Log only safe fields; never serialize the full error object because it
98+
// includes request config/headers which carry the bearer session token.
99+
const err = error as {
100+
message?: string
101+
name?: string
102+
code?: string
103+
response?: { status?: number; statusText?: string }
104+
}
93105
console.error(
94-
`Error fetching Zoo Gateway models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
106+
`Error fetching Zoo Gateway models: name=${err.name ?? "Error"} code=${err.code ?? "unknown"} status=${err.response?.status ?? "unknown"} ${err.response?.statusText ?? ""} message=${err.message ?? "unknown error"}`,
95107
)
96108
}
97109

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,58 @@ describe("webviewMessageHandler - requestRouterModels", () => {
377377
})
378378
})
379379

380+
it("recovers zoo-gateway credentials from a separate profile when not the active provider", async () => {
381+
// Regression: when zoo-gateway is NOT the active provider, the active apiConfiguration
382+
// will not contain zooSessionToken / zooGatewayBaseUrl. The aggregate model fetcher must
383+
// fall back to scanning providerSettingsManager profiles for a zoo-gateway entry so the
384+
// model picker still populates.
385+
mockClineProvider.getState = vi.fn().mockResolvedValue({
386+
apiConfiguration: {
387+
// Active provider is anthropic (or anything other than zoo-gateway).
388+
apiProvider: "anthropic",
389+
openRouterApiKey: "openrouter-key",
390+
requestyApiKey: "requesty-key",
391+
// No zooSessionToken / zooGatewayBaseUrl on the active config.
392+
},
393+
})
394+
;(mockClineProvider as any).providerSettingsManager = {
395+
listConfig: vi.fn().mockResolvedValue([
396+
{ name: "Anthropic", apiProvider: "anthropic" },
397+
{ name: "My Zoo Gateway Profile", apiProvider: "zoo-gateway" },
398+
]),
399+
getProfile: vi.fn().mockResolvedValue({
400+
apiProvider: "zoo-gateway",
401+
zooSessionToken: "recovered-zoo-token",
402+
zooGatewayBaseUrl: "https://zoo.example.com/api/gateway/v1",
403+
}),
404+
}
405+
406+
const mockModels: ModelRecord = {
407+
"model-1": {
408+
maxTokens: 4096,
409+
contextWindow: 8192,
410+
supportsPromptCache: false,
411+
description: "Test model 1",
412+
},
413+
}
414+
mockGetModels.mockResolvedValue(mockModels)
415+
416+
await webviewMessageHandler(mockClineProvider, { type: "requestRouterModels" })
417+
418+
expect((mockClineProvider as any).providerSettingsManager.listConfig).toHaveBeenCalled()
419+
expect((mockClineProvider as any).providerSettingsManager.getProfile).toHaveBeenCalledWith({
420+
name: "My Zoo Gateway Profile",
421+
})
422+
expect(mockGetModels).toHaveBeenCalledWith({
423+
provider: "zoo-gateway",
424+
apiKey: "recovered-zoo-token",
425+
baseUrl: "https://zoo.example.com/api/gateway/v1",
426+
})
427+
428+
// Reset to avoid bleeding into other tests
429+
delete (mockClineProvider as any).providerSettingsManager
430+
})
431+
380432
it("handles LiteLLM models with values from message when config is missing", async () => {
381433
mockClineProvider.getState = vi.fn().mockResolvedValue({
382434
apiConfiguration: {

src/core/webview/webviewMessageHandler.ts

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2476,27 +2476,23 @@ export const webviewMessageHandler = async (
24762476
for (const entry of allProfiles) {
24772477
if (entry.apiProvider === "zoo-gateway") {
24782478
const profile = await provider.providerSettingsManager.getProfile({ name: entry.name })
2479-
if (profile.zooSessionToken) {
2480-
// Clear the token from the profile
2481-
const { zooSessionToken: _removed, ...cleanedProfile } = profile
2482-
2483-
// If this is the currently active profile, push to in-memory handler
2484-
// so the current Task's API handler doesn't retain the stale token.
2485-
const isThisProfileActive = isZooGatewayActive && currentApiConfigName === entry.name
2486-
2487-
if (isThisProfileActive) {
2488-
// Push cleared profile to in-memory handler
2489-
await provider.upsertProviderProfile(entry.name, cleanedProfile, true)
2490-
provider.log(
2491-
`[zooCodeSignOut] Cleared zooSessionToken from "${entry.name}" profile and updated in-memory handler`,
2492-
)
2493-
} else {
2494-
// Just persist to disk; this profile is not currently active
2495-
await provider.providerSettingsManager.saveConfig(entry.name, cleanedProfile)
2496-
provider.log(
2497-
`[zooCodeSignOut] Cleared zooSessionToken from "${entry.name}" profile`,
2498-
)
2499-
}
2479+
const { zooSessionToken: _removed, ...cleanedProfile } = profile
2480+
2481+
// If this is the currently active profile, ALWAYS push to the in-memory
2482+
// handler — even when the persisted profile has already been cleared —
2483+
// because currentSettings (and therefore the live API handler) may still
2484+
// carry a stale token from before sign-out. Persisted-only profiles get
2485+
// rewritten only when they previously had a token to avoid no-op disk writes.
2486+
const isThisProfileActive = isZooGatewayActive && currentApiConfigName === entry.name
2487+
2488+
if (isThisProfileActive) {
2489+
await provider.upsertProviderProfile(entry.name, cleanedProfile, true)
2490+
provider.log(
2491+
`[zooCodeSignOut] Cleared zooSessionToken from "${entry.name}" profile and updated in-memory handler`,
2492+
)
2493+
} else if (profile.zooSessionToken) {
2494+
await provider.providerSettingsManager.saveConfig(entry.name, cleanedProfile)
2495+
provider.log(`[zooCodeSignOut] Cleared zooSessionToken from "${entry.name}" profile`)
25002496
}
25012497
}
25022498
}

src/services/zoo-telemetry.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ export type LlmTelemetryPayload = {
1818
* Send LLM telemetry to the Zoo Code observability backend.
1919
* This is a fire-and-forget operation that silently fails on error.
2020
* Sends telemetry for all authenticated users — free and paid alike.
21-
* Retention limits (7 days for free, unlimited for Pro) are enforced server-side.
21+
* Server-side retention follows the zoocode.dev privacy policy (metadata-only
22+
* API request logs are kept up to 90 days). Dashboard visibility is plan-gated
23+
* (7 days for Free; full window for Pro and higher).
2224
*/
2325
export async function sendLlmTelemetry(payload: LlmTelemetryPayload): Promise<void> {
2426
const token = getCachedZooCodeToken()

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
mainlandZAiDefaultModelId,
3131
fireworksDefaultModelId,
3232
vercelAiGatewayDefaultModelId,
33+
zooGatewayDefaultModelId,
3334
minimaxDefaultModelId,
3435
mimoDefaultModelId,
3536
unboundDefaultModelId,
@@ -372,6 +373,7 @@ const ApiOptions = ({
372373
fireworks: { field: "apiModelId", default: fireworksDefaultModelId },
373374
poe: { field: "apiModelId", default: poeDefaultModelId },
374375
"vercel-ai-gateway": { field: "vercelAiGatewayModelId", default: vercelAiGatewayDefaultModelId },
376+
"zoo-gateway": { field: "zooGatewayModelId", default: zooGatewayDefaultModelId },
375377
openai: { field: "openAiModelId" },
376378
ollama: { field: "ollamaModelId" },
377379
lmstudio: { field: "lmStudioModelId" },

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { render, screen, fireEvent, within } from "@/utils/test-utils"
44
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
55

66
import { type ModelInfo, type ProviderSettings, openAiModelInfoSaneDefaults } from "@roo-code/types"
7-
import { openAiCodexDefaultModelId } from "@roo-code/types"
7+
import { openAiCodexDefaultModelId, zooGatewayDefaultModelId } from "@roo-code/types"
88

99
import * as ExtensionStateContext from "@src/context/ExtensionStateContext"
1010
const { ExtensionStateContextProvider } = ExtensionStateContext
@@ -300,6 +300,28 @@ describe("ApiOptions", () => {
300300
expect(mockSetApiConfigurationField).toHaveBeenCalledWith("apiModelId", openAiCodexDefaultModelId, false)
301301
})
302302

303+
it("initializes zooGatewayModelId to its default when switching provider to zoo-gateway", () => {
304+
// Regression: zoo-gateway was previously missing from PROVIDER_MODEL_CONFIG, so switching
305+
// providers never seeded zooGatewayModelId. Configs were left without a model id, which
306+
// blocked completion flows that require a dynamic-provider model id.
307+
const mockSetApiConfigurationField = vi.fn()
308+
309+
renderApiOptions({
310+
apiConfiguration: {
311+
apiProvider: "anthropic",
312+
// No prior zooGatewayModelId.
313+
},
314+
setApiConfigurationField: mockSetApiConfigurationField,
315+
})
316+
317+
const providerSelectContainer = screen.getByTestId("provider-select")
318+
const providerSelect = providerSelectContainer.querySelector("select") as HTMLSelectElement
319+
fireEvent.change(providerSelect, { target: { value: "zoo-gateway" } })
320+
321+
expect(mockSetApiConfigurationField).toHaveBeenCalledWith("apiProvider", "zoo-gateway")
322+
expect(mockSetApiConfigurationField).toHaveBeenCalledWith("zooGatewayModelId", zooGatewayDefaultModelId, false)
323+
})
324+
303325
it("shows temperature and rate limit controls by default", () => {
304326
renderApiOptions({
305327
apiConfiguration: {},

0 commit comments

Comments
 (0)