Skip to content

Commit 9ae1c35

Browse files
James Mtendamemacursoragent
andcommitted
fix(zoo-gateway): respect readonly client, real version header, safer fetch
- Stop reassigning RouterProvider.client; thread Zoo enrichment headers through openAiHeaders so a single OpenAI client is used. - Replace npm_package_version (never populated at extension runtime) with Package.version from the shared package shim. - Default the model list to [] on a structurally broken response so we log and recover instead of crashing on response.data.data being undefined. - Bypass inFlightRefresh de-duplication for zoo-gateway: a refresh triggered after sign-out/sign-in must not return the previous user's in-flight response. - Add fetcher unit tests covering auth header, timeout, error redaction, and bad-response handling. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 641b2c3 commit 9ae1c35

4 files changed

Lines changed: 184 additions & 34 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// npx vitest run src/api/providers/fetchers/__tests__/zoo-gateway.spec.ts
2+
3+
import axios from "axios"
4+
5+
import { getZooGatewayModels, parseZooGatewayModel } from "../zoo-gateway"
6+
7+
vitest.mock("axios")
8+
const mockedAxios = axios as any
9+
10+
describe("Zoo Gateway Fetchers", () => {
11+
beforeEach(() => {
12+
vitest.clearAllMocks()
13+
})
14+
15+
describe("getZooGatewayModels", () => {
16+
const baseUrl = "https://example.test/api/gateway/v1"
17+
const token = "zoo_ext_test_token"
18+
19+
const mockResponse = {
20+
data: {
21+
object: "list",
22+
data: [
23+
{
24+
id: "anthropic/claude-sonnet-4",
25+
object: "model",
26+
created: 1640995200,
27+
owned_by: "anthropic",
28+
name: "Claude Sonnet 4",
29+
description: "Sonnet 4",
30+
context_window: 200000,
31+
max_tokens: 64000,
32+
type: "language",
33+
pricing: {
34+
input: "3.00",
35+
output: "15.00",
36+
input_cache_write: "3.75",
37+
input_cache_read: "0.30",
38+
},
39+
},
40+
{
41+
id: "image/dall-e-3",
42+
object: "model",
43+
created: 1640995200,
44+
owned_by: "openai",
45+
name: "DALL-E 3",
46+
description: "Image",
47+
context_window: 4000,
48+
max_tokens: 1000,
49+
type: "image",
50+
pricing: { input: "40.00", output: "0.00" },
51+
},
52+
],
53+
},
54+
}
55+
56+
it("forwards the bearer token and timeout, filters non-language models", async () => {
57+
mockedAxios.get.mockResolvedValueOnce(mockResponse)
58+
59+
const models = await getZooGatewayModels({
60+
zooGatewayBaseUrl: baseUrl,
61+
zooSessionToken: token,
62+
} as any)
63+
64+
expect(mockedAxios.get).toHaveBeenCalledWith(
65+
`${baseUrl}/models`,
66+
expect.objectContaining({
67+
headers: expect.objectContaining({ Authorization: `Bearer ${token}` }),
68+
timeout: expect.any(Number),
69+
}),
70+
)
71+
expect(Object.keys(models)).toHaveLength(1)
72+
expect(models["anthropic/claude-sonnet-4"]).toBeDefined()
73+
})
74+
75+
it("omits the Authorization header when no token is provided", async () => {
76+
mockedAxios.get.mockResolvedValueOnce(mockResponse)
77+
78+
await getZooGatewayModels({ zooGatewayBaseUrl: baseUrl } as any)
79+
80+
const call = mockedAxios.get.mock.calls[0]
81+
expect(call[1].headers.Authorization).toBeUndefined()
82+
})
83+
84+
it("returns {} and never leaks the error object when the request fails", async () => {
85+
const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(() => {})
86+
const failure: any = new Error("Network error")
87+
// Simulate axios attaching the request config (which contains the bearer token).
88+
failure.config = { headers: { Authorization: "Bearer should-never-be-logged" } }
89+
failure.code = "ECONNRESET"
90+
failure.response = { status: 502, statusText: "Bad Gateway" }
91+
mockedAxios.get.mockRejectedValueOnce(failure)
92+
93+
const models = await getZooGatewayModels({
94+
zooGatewayBaseUrl: baseUrl,
95+
zooSessionToken: token,
96+
} as any)
97+
98+
expect(models).toEqual({})
99+
const logged = consoleErrorSpy.mock.calls.map((args) => String(args[0])).join("\n")
100+
expect(logged).toContain("status=502")
101+
expect(logged).toContain("code=ECONNRESET")
102+
expect(logged).not.toContain("should-never-be-logged")
103+
expect(logged).not.toContain("Authorization")
104+
consoleErrorSpy.mockRestore()
105+
})
106+
107+
it("returns {} on a structurally broken response instead of throwing", async () => {
108+
const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(() => {})
109+
mockedAxios.get.mockResolvedValueOnce({ data: { unexpected: true } })
110+
111+
const models = await getZooGatewayModels({
112+
zooGatewayBaseUrl: baseUrl,
113+
zooSessionToken: token,
114+
} as any)
115+
116+
expect(models).toEqual({})
117+
expect(consoleErrorSpy).toHaveBeenCalled()
118+
consoleErrorSpy.mockRestore()
119+
})
120+
})
121+
122+
describe("parseZooGatewayModel", () => {
123+
it("delegates to the vercel-ai-gateway parser", () => {
124+
const result = parseZooGatewayModel({
125+
id: "anthropic/claude-sonnet-4",
126+
model: {
127+
id: "anthropic/claude-sonnet-4",
128+
object: "model",
129+
created: 0,
130+
owned_by: "anthropic",
131+
name: "Claude Sonnet 4",
132+
description: "Sonnet",
133+
context_window: 200000,
134+
max_tokens: 64000,
135+
type: "language",
136+
pricing: {
137+
input: "3.00",
138+
output: "15.00",
139+
input_cache_write: "3.75",
140+
input_cache_read: "0.30",
141+
},
142+
} as any,
143+
})
144+
145+
expect(result.contextWindow).toBe(200000)
146+
expect(result.maxTokens).toBe(64000)
147+
expect(result.supportsPromptCache).toBe(true)
148+
})
149+
})
150+
})

src/api/providers/fetchers/modelCache.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -176,12 +176,17 @@ export const refreshModels = async (options: GetModelsOptions): Promise<ModelRec
176176
// different authenticated user from cache.
177177
const shouldSkipCache = provider === "zoo-gateway"
178178

179-
// Check if there's already an in-flight refresh for this provider
179+
// Check if there's already an in-flight refresh for this provider.
180180
// This prevents race conditions where multiple concurrent refreshes might
181-
// overwrite each other's results
182-
const existingRequest = inFlightRefresh.get(provider)
183-
if (existingRequest) {
184-
return existingRequest
181+
// overwrite each other's results. Skip de-duplication for auth-scoped
182+
// providers because two concurrent calls may carry different tokens
183+
// (e.g., after a sign-out/sign-in within the same session) and we must
184+
// not return the first caller's results to the second caller.
185+
if (!shouldSkipCache) {
186+
const existingRequest = inFlightRefresh.get(provider)
187+
if (existingRequest) {
188+
return existingRequest
189+
}
185190
}
186191

187192
// Create the refresh promise and track it
@@ -229,12 +234,16 @@ export const refreshModels = async (options: GetModelsOptions): Promise<ModelRec
229234
return getModelsFromCache(provider) || {}
230235
} finally {
231236
// Always clean up the in-flight tracking
232-
inFlightRefresh.delete(provider)
237+
if (!shouldSkipCache) {
238+
inFlightRefresh.delete(provider)
239+
}
233240
}
234241
})()
235242

236-
// Track the in-flight request
237-
inFlightRefresh.set(provider, refreshPromise)
243+
// Track the in-flight request (auth-scoped providers are excluded; see above).
244+
if (!shouldSkipCache) {
245+
inFlightRefresh.set(provider, refreshPromise)
246+
}
238247

239248
return refreshPromise
240249
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ export async function getZooGatewayModels(options?: ApiHandlerOptions): Promise<
7575
timeout: MODEL_DISCOVERY_TIMEOUT_MS,
7676
})
7777
const result = zooGatewayModelsResponseSchema.safeParse(response.data)
78-
const data = result.success ? result.data.data : response.data.data
78+
79+
// Fall back to the raw response only when it looks structurally sound; otherwise return
80+
// an empty list rather than crashing on `response.data.data` being undefined.
81+
const data = result.success ? result.data.data : Array.isArray(response.data?.data) ? response.data.data : []
7982

8083
if (!result.success) {
8184
console.error(`Zoo Gateway models response is invalid ${JSON.stringify(result.error.format())}`)

src/api/providers/zoo-gateway.ts

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010

1111
import { ApiHandlerOptions } from "../../shared/api"
1212
import { getZooCodeBaseUrl } from "../../services/zoo-code-auth"
13+
import { Package } from "../../shared/package"
1314

1415
import { ApiStream } from "../transform/stream"
1516
import { convertToOpenAiMessages } from "../transform/openai-format"
@@ -18,8 +19,6 @@ import { addCacheBreakpoints } from "../transform/caching/vercel-ai-gateway"
1819
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
1920
import { RouterProvider } from "./router-provider"
2021

21-
import { DEFAULT_HEADERS } from "./constants"
22-
2322
// Extend OpenAI's CompletionUsage to include Zoo Gateway specific fields (same as Vercel AI Gateway)
2423
interface ZooGatewayUsage extends OpenAI.CompletionUsage {
2524
cache_creation_input_tokens?: number
@@ -37,37 +36,26 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio
3736
throw new Error("Zoo Gateway requires authentication. Please sign in to Zoo Code first.")
3837
}
3938

39+
// Merge Zoo-specific enrichment headers into openAiHeaders so they flow through
40+
// the parent's single OpenAI client. We avoid reassigning `this.client` (which
41+
// is declared readonly on RouterProvider) and the wasted client allocation it
42+
// caused. Per-request headers (task id / mode) are set in createMessage below.
4043
super({
41-
options,
44+
options: {
45+
...options,
46+
openAiHeaders: {
47+
"X-Zoo-Editor": "vscode",
48+
"X-Zoo-Extension-Version": Package.version,
49+
...(options.openAiHeaders || {}),
50+
},
51+
},
4252
name: "zoo-gateway",
4353
baseURL,
4454
apiKey: options.zooSessionToken,
4555
modelId: options.zooGatewayModelId,
4656
defaultModelId: zooGatewayDefaultModelId,
4757
defaultModelInfo: zooGatewayDefaultModelInfo,
4858
})
49-
50-
// Override the client to add Zoo-specific enrichment headers
51-
// These headers help with request tracking and analytics
52-
const enrichmentHeaders: Record<string, string> = {}
53-
54-
// Note: These headers will be populated per-request in createMessage
55-
// For now we just set static headers that are always available
56-
if (typeof process !== "undefined" && process.env?.npm_package_version) {
57-
enrichmentHeaders["X-Zoo-Extension-Version"] = process.env.npm_package_version
58-
}
59-
enrichmentHeaders["X-Zoo-Editor"] = "vscode"
60-
61-
// Recreate client with enrichment headers
62-
;(this as any).client = new OpenAI({
63-
baseURL,
64-
apiKey: options.zooSessionToken,
65-
defaultHeaders: {
66-
...DEFAULT_HEADERS,
67-
...enrichmentHeaders,
68-
...(options.openAiHeaders || {}),
69-
},
70-
})
7159
}
7260

7361
override async *createMessage(

0 commit comments

Comments
 (0)