Skip to content

Commit cd9aaf9

Browse files
James Mtendamemacursoragent
andcommitted
fix(zoo-gateway): sign-out clears stale profile tokens, simplify model fetch
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8dab953 commit cd9aaf9

7 files changed

Lines changed: 106 additions & 104 deletions

File tree

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

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,21 @@ vitest.mock("../fetchers/modelCache", () => ({
5757
}))
5858

5959
const mockGetCachedZooCodeToken = vitest.hoisted(() => vitest.fn<() => string | undefined>(() => undefined))
60+
const mockSessionCleared = vitest.hoisted(() => ({ value: false }))
6061

6162
vitest.mock("../../../services/zoo-code-auth", () => ({
6263
getZooCodeBaseUrl: vitest.fn(() => "https://www.zoocode.dev"),
63-
getCachedZooCodeToken: mockGetCachedZooCodeToken,
64-
clearZooCodeToken: vitest.fn(async () => undefined),
64+
getCachedZooCodeToken: () => mockGetCachedZooCodeToken() ?? "",
65+
resolveZooGatewaySessionToken: (profileToken?: string) => {
66+
const cached = mockGetCachedZooCodeToken()
67+
if (cached) return cached
68+
if (mockSessionCleared.value) return undefined
69+
return profileToken
70+
},
71+
clearZooCodeToken: vitest.fn(async () => {
72+
mockSessionCleared.value = true
73+
mockGetCachedZooCodeToken.mockReturnValue(undefined)
74+
}),
6575
}))
6676

6777
vitest.mock("../../transform/caching/vercel-ai-gateway", () => ({
@@ -93,6 +103,7 @@ describe("ZooGatewayHandler", () => {
93103

94104
beforeEach(() => {
95105
vitest.clearAllMocks()
106+
mockSessionCleared.value = false
96107
mockGetCachedZooCodeToken.mockReturnValue(undefined)
97108
mockCreate.mockClear()
98109
showErrorMessage.mockReset()
@@ -122,11 +133,13 @@ describe("ZooGatewayHandler", () => {
122133
}
123134

124135
describe("constructor", () => {
125-
it("requires authentication before constructing the client", () => {
126-
expect(() => new ZooGatewayHandler({})).toThrow(
127-
"Zoo Gateway requires authentication. Please sign in to Zoo Code first.",
136+
it("allows construction without a session token (auth is enforced at request time)", () => {
137+
expect(() => new ZooGatewayHandler({})).not.toThrow()
138+
expect(OpenAI).toHaveBeenCalledWith(
139+
expect.objectContaining({
140+
apiKey: "not-provided",
141+
}),
128142
)
129-
expect(OpenAI).not.toHaveBeenCalled()
130143
})
131144

132145
it("prefers the secret-storage cache over a persisted profile token", () => {
@@ -193,6 +206,13 @@ describe("ZooGatewayHandler", () => {
193206
})
194207

195208
describe("createMessage", () => {
209+
it("requires authentication at request time when no session token is available", async () => {
210+
const handler = new ZooGatewayHandler({})
211+
await expect(drainCreateMessage(handler)).rejects.toThrow(
212+
"Zoo Gateway requires authentication. Please sign in to Zoo Code first.",
213+
)
214+
})
215+
196216
beforeEach(() => {
197217
mockCreate.mockImplementation(async () => ({
198218
[Symbol.asyncIterator]: async function* () {

src/api/providers/fetchers/vercel-ai-gateway.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export type VercelAiGatewayModel = z.infer<typeof vercelAiGatewayModelSchema>
4242
* VercelAiGatewayModelsResponse
4343
*/
4444

45-
const vercelAiGatewayModelsResponseSchema = z.object({
45+
export const vercelAiGatewayModelsResponseSchema = z.object({
4646
object: z.string(),
4747
data: z.array(vercelAiGatewayModelSchema),
4848
})

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

Lines changed: 12 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -3,52 +3,13 @@ import axios from "axios"
33
import type { ModelInfo } from "@roo-code/types"
44

55
import type { ApiHandlerOptions } from "../../../shared/api"
6-
import { getCachedZooCodeToken, getZooCodeBaseUrl } from "../../../services/zoo-code-auth"
6+
import { getZooCodeBaseUrl, resolveZooGatewaySessionToken } from "../../../services/zoo-code-auth"
77

8-
// Reuse the same schemas and parsing logic from vercel-ai-gateway since the API format is identical
9-
import { type VercelAiGatewayModel, parseVercelAiGatewayModel } from "./vercel-ai-gateway"
10-
11-
import { z } from "zod"
12-
13-
/**
14-
* ZooGatewayPricing (same format as Vercel AI Gateway)
15-
*/
16-
17-
const zooGatewayPricingSchema = z.object({
18-
input: z.string().optional(),
19-
output: z.string().optional(),
20-
input_cache_write: z.string().optional(),
21-
input_cache_read: z.string().optional(),
22-
image: z.string().optional(),
23-
})
24-
25-
/**
26-
* ZooGatewayModel (same format as Vercel AI Gateway)
27-
*/
28-
29-
const zooGatewayModelSchema = z.object({
30-
id: z.string(),
31-
object: z.string(),
32-
created: z.number(),
33-
owned_by: z.string(),
34-
name: z.string(),
35-
description: z.string(),
36-
context_window: z.number(),
37-
max_tokens: z.number(),
38-
type: z.string(),
39-
pricing: zooGatewayPricingSchema,
40-
})
41-
42-
/**
43-
* ZooGatewayModelsResponse
44-
*/
45-
46-
const zooGatewayModelsResponseSchema = z.object({
47-
object: z.string(),
48-
data: z.array(zooGatewayModelSchema),
49-
})
50-
51-
type ZooGatewayModelsResponse = z.infer<typeof zooGatewayModelsResponseSchema>
8+
import {
9+
type VercelAiGatewayModel,
10+
parseVercelAiGatewayModel,
11+
vercelAiGatewayModelsResponseSchema,
12+
} from "./vercel-ai-gateway"
5213

5314
// Bound model discovery so a network stall can't hang provider initialization paths.
5415
const MODEL_DISCOVERY_TIMEOUT_MS = 15_000
@@ -63,30 +24,25 @@ export async function getZooGatewayModels(options?: ApiHandlerOptions): Promise<
6324
const models: Record<string, ModelInfo> = {}
6425
const baseURL = options?.zooGatewayBaseUrl ?? `${getZooCodeBaseUrl()}/api/gateway/v1`
6526

66-
// Build headers - Zoo Gateway requires authentication via the zoo_ext_ session token.
67-
// Fall back to the secret-storage cache when the profile hasn't been seeded yet.
68-
const sessionToken = options?.zooSessionToken || getCachedZooCodeToken()
27+
const sessionToken = resolveZooGatewaySessionToken(options?.zooSessionToken)
6928
const headers: Record<string, string> = {}
7029
if (sessionToken) {
7130
headers["Authorization"] = `Bearer ${sessionToken}`
7231
}
7332

7433
try {
75-
const response = await axios.get<ZooGatewayModelsResponse>(`${baseURL}/models`, {
34+
const response = await axios.get(`${baseURL}/models`, {
7635
headers,
7736
timeout: MODEL_DISCOVERY_TIMEOUT_MS,
7837
})
79-
const result = zooGatewayModelsResponseSchema.safeParse(response.data)
80-
81-
// Fall back to the raw response only when it looks structurally sound; otherwise return
82-
// an empty list rather than crashing on `response.data.data` being undefined.
83-
const data = result.success ? result.data.data : Array.isArray(response.data?.data) ? response.data.data : []
38+
const result = vercelAiGatewayModelsResponseSchema.safeParse(response.data)
8439

8540
if (!result.success) {
8641
console.error(`Zoo Gateway models response is invalid ${JSON.stringify(result.error.format())}`)
42+
return models
8743
}
8844

89-
for (const model of data) {
45+
for (const model of result.data.data) {
9046
const { id } = model
9147

9248
// Only include language models for chat inference.
@@ -95,8 +51,7 @@ export async function getZooGatewayModels(options?: ApiHandlerOptions): Promise<
9551
continue
9652
}
9753

98-
// Parse model using the same logic as Vercel AI Gateway since formats are identical
99-
models[id] = parseZooGatewayModel({ id, model: model as VercelAiGatewayModel })
54+
models[id] = parseZooGatewayModel({ id, model })
10055
}
10156
} catch (error) {
10257
// Log only safe fields; never serialize the full error object because it
@@ -123,6 +78,5 @@ export async function getZooGatewayModels(options?: ApiHandlerOptions): Promise<
12378
*/
12479

12580
export const parseZooGatewayModel = ({ id, model }: { id: string; model: VercelAiGatewayModel }): ModelInfo => {
126-
// Reuse the parsing logic from vercel-ai-gateway
12781
return parseVercelAiGatewayModel({ id, model })
12882
}

src/api/providers/zoo-gateway.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from "@roo-code/types"
1111

1212
import { ApiHandlerOptions } from "../../shared/api"
13-
import { clearZooCodeToken, getCachedZooCodeToken, getZooCodeBaseUrl } from "../../services/zoo-code-auth"
13+
import { clearZooCodeToken, getZooCodeBaseUrl, resolveZooGatewaySessionToken } from "../../services/zoo-code-auth"
1414
import { Package } from "../../shared/package"
1515
import { t } from "../../i18n"
1616

@@ -95,16 +95,13 @@ interface ZooGatewayUsage extends OpenAI.CompletionUsage {
9595
cost?: number
9696
}
9797

98+
const ZOO_GATEWAY_AUTH_ERROR = "Zoo Gateway requires authentication. Please sign in to Zoo Code first."
99+
98100
export class ZooGatewayHandler extends RouterProvider implements SingleCompletionHandler {
99101
constructor(options: ApiHandlerOptions) {
100102
const baseURL = options.zooGatewayBaseUrl ?? `${getZooCodeBaseUrl()}/api/gateway/v1`
101103

102-
// Prefer the secret-storage cache so a 401 clear takes effect immediately; fall back
103-
// to the profile-persisted token when the user is signed in but seeding hasn't run yet.
104-
const sessionToken = getCachedZooCodeToken() || options.zooSessionToken
105-
if (!sessionToken) {
106-
throw new Error("Zoo Gateway requires authentication. Please sign in to Zoo Code first.")
107-
}
104+
const sessionToken = resolveZooGatewaySessionToken(options.zooSessionToken)
108105

109106
// Merge Zoo-specific enrichment headers into openAiHeaders so they flow through
110107
// the parent's single OpenAI client. We avoid reassigning `this.client` (which
@@ -121,18 +118,26 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio
121118
},
122119
name: "zoo-gateway",
123120
baseURL,
124-
apiKey: sessionToken,
121+
apiKey: sessionToken || "not-provided",
125122
modelId: options.zooGatewayModelId,
126123
defaultModelId: zooGatewayDefaultModelId,
127124
defaultModelInfo: zooGatewayDefaultModelInfo,
128125
})
129126
}
130127

128+
private ensureAuthenticated(): void {
129+
if (!resolveZooGatewaySessionToken(this.options.zooSessionToken)) {
130+
throw new Error(ZOO_GATEWAY_AUTH_ERROR)
131+
}
132+
}
133+
131134
override async *createMessage(
132135
systemPrompt: string,
133136
messages: Anthropic.Messages.MessageParam[],
134137
metadata?: ApiHandlerCreateMessageMetadata,
135138
): ApiStream {
139+
this.ensureAuthenticated()
140+
136141
const { id: modelId, info } = await this.fetchModel()
137142

138143
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
@@ -209,17 +214,21 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio
209214
}
210215
}
211216
} catch (error) {
212-
void surfaceGatewayApiError(error).catch((surfaceError) => {
217+
try {
218+
await surfaceGatewayApiError(error)
219+
} catch (surfaceError) {
213220
console.error(
214221
"Failed to surface Zoo Gateway error:",
215222
surfaceError instanceof Error ? surfaceError.message : surfaceError,
216223
)
217-
})
224+
}
218225
throw error
219226
}
220227
}
221228

222229
async completePrompt(prompt: string): Promise<string> {
230+
this.ensureAuthenticated()
231+
223232
const { id: modelId, info } = await this.fetchModel()
224233

225234
try {
@@ -238,12 +247,14 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio
238247
const response = await this.client.chat.completions.create(requestOptions)
239248
return response.choices[0]?.message.content || ""
240249
} catch (error) {
241-
void surfaceGatewayApiError(error).catch((surfaceError) => {
250+
try {
251+
await surfaceGatewayApiError(error)
252+
} catch (surfaceError) {
242253
console.error(
243254
"Failed to surface Zoo Gateway error:",
244255
surfaceError instanceof Error ? surfaceError.message : surfaceError,
245256
)
246-
})
257+
}
247258
if (error instanceof Error) {
248259
throw new Error(`Zoo Gateway completion error: ${error.message}`)
249260
}

src/core/webview/webviewMessageHandler.ts

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -946,31 +946,6 @@ export const webviewMessageHandler = async (
946946
}
947947
}
948948

949-
// For zoo-gateway, the token may be stored in a separate zoo-gateway profile
950-
// (not the currently active profile). Look it up so the model list populates
951-
// even when zoo-gateway isn't the active provider.
952-
let zooGatewayToken = apiConfiguration.zooSessionToken
953-
let zooGatewayBaseUrl = apiConfiguration.zooGatewayBaseUrl
954-
955-
if (!zooGatewayToken) {
956-
try {
957-
const allProfiles = await provider.providerSettingsManager.listConfig()
958-
const zooGatewayProfiles = allProfiles.filter((p) => p.apiProvider === "zoo-gateway")
959-
for (const profileMeta of zooGatewayProfiles) {
960-
const fullProfile = await provider.providerSettingsManager.getProfile({
961-
name: profileMeta.name,
962-
})
963-
if (fullProfile.zooSessionToken) {
964-
zooGatewayToken = fullProfile.zooSessionToken
965-
zooGatewayBaseUrl = fullProfile.zooGatewayBaseUrl ?? zooGatewayBaseUrl
966-
break
967-
}
968-
}
969-
} catch (error) {
970-
console.debug("Failed to look up zoo-gateway profile for model fetch:", error)
971-
}
972-
}
973-
974949
// Base candidates (only those handled by this aggregate fetcher)
975950
const candidates: { key: RouterName; options: GetModelsOptions }[] = [
976951
{ key: "openrouter", options: { provider: "openrouter" } },
@@ -994,8 +969,7 @@ export const webviewMessageHandler = async (
994969
key: "zoo-gateway",
995970
options: {
996971
provider: "zoo-gateway",
997-
apiKey: zooGatewayToken,
998-
baseUrl: zooGatewayBaseUrl,
972+
baseUrl: apiConfiguration.zooGatewayBaseUrl,
999973
},
1000974
},
1001975
]

src/services/__tests__/zoo-code-auth.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
getZooCodeBaseUrl,
1313
handleAuthCallback,
1414
initZooCodeAuth,
15+
resolveZooGatewaySessionToken,
1516
setZooCodeToken,
1617
setZooCodeUserInfo,
1718
verifyZooCodeToken,
@@ -432,6 +433,29 @@ describe("zoo-code-auth", () => {
432433
})
433434
})
434435

436+
describe("resolveZooGatewaySessionToken", () => {
437+
it("prefers the cached token over a profile token", async () => {
438+
await initZooCodeAuth(mockContext)
439+
await setZooCodeToken("zoo_ext_cached")
440+
441+
expect(resolveZooGatewaySessionToken("zoo_ext_profile")).toBe("zoo_ext_cached")
442+
})
443+
444+
it("ignores profile tokens after an explicit sign-out clear", async () => {
445+
await initZooCodeAuth(mockContext)
446+
await setZooCodeToken("zoo_ext_cached")
447+
await clearZooCodeToken()
448+
449+
expect(resolveZooGatewaySessionToken("zoo_ext_stale_profile")).toBeUndefined()
450+
})
451+
452+
it("falls back to the profile token when the cache is empty and not cleared", async () => {
453+
await initZooCodeAuth(mockContext)
454+
455+
expect(resolveZooGatewaySessionToken("zoo_ext_profile")).toBe("zoo_ext_profile")
456+
})
457+
})
458+
435459
describe("disconnectZooCode", () => {
436460
it("revokes the current token and clears cached auth state", async () => {
437461
await initZooCodeAuth(mockContext)

0 commit comments

Comments
 (0)