Skip to content

Commit 2dde8b5

Browse files
James Mtendamemacursoragent
andcommitted
fix(zoo-gateway): settings UI sign-in button, validation tests, defer auth
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 946d6d1 commit 2dde8b5

6 files changed

Lines changed: 97 additions & 72 deletions

File tree

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,13 @@ describe("ZooGatewayHandler", () => {
8282
})
8383

8484
describe("constructor", () => {
85-
it("requires authentication before constructing the client", () => {
86-
expect(() => new ZooGatewayHandler({})).toThrow(
87-
"Zoo Gateway requires authentication. Please sign in to Zoo Code first.",
85+
it("allows construction without a session token (auth is enforced at request time)", () => {
86+
expect(() => new ZooGatewayHandler({})).not.toThrow()
87+
expect(OpenAI).toHaveBeenCalledWith(
88+
expect.objectContaining({
89+
apiKey: "not-provided",
90+
}),
8891
)
89-
expect(OpenAI).not.toHaveBeenCalled()
9092
})
9193

9294
it("initializes OpenAI with Zoo enrichment headers and session token", () => {
@@ -160,6 +162,17 @@ describe("ZooGatewayHandler", () => {
160162
}))
161163
})
162164

165+
it("requires authentication at request time when no session token is available", async () => {
166+
const handler = new ZooGatewayHandler({})
167+
const stream = handler.createMessage("You are helpful.", [{ role: "user", content: "Hello" }])
168+
169+
await expect(async () => {
170+
for await (const _chunk of stream) {
171+
// drain
172+
}
173+
}).rejects.toThrow("Zoo Gateway requires authentication. Please sign in to Zoo Code first.")
174+
})
175+
163176
it("streams text and usage chunks", async () => {
164177
const handler = new ZooGatewayHandler(mockOptions)
165178
const stream = handler.createMessage("You are helpful.", [{ role: "user", content: "Hello" }])

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
})
Lines changed: 11 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,15 @@
1-
import axios from "axios"
1+
import axios from "axios"
22

33
import type { ModelInfo } from "@roo-code/types"
44

55
import type { ApiHandlerOptions } from "../../../shared/api"
66
import { getCachedZooCodeToken, getZooCodeBaseUrl } 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
@@ -72,21 +33,18 @@ export async function getZooGatewayModels(options?: ApiHandlerOptions): Promise<
7233
}
7334

7435
try {
75-
const response = await axios.get<ZooGatewayModelsResponse>(`${baseURL}/models`, {
36+
const response = await axios.get(`${baseURL}/models`, {
7637
headers,
7738
timeout: MODEL_DISCOVERY_TIMEOUT_MS,
7839
})
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 : []
40+
const result = vercelAiGatewayModelsResponseSchema.safeParse(response.data)
8441

8542
if (!result.success) {
8643
console.error(`Zoo Gateway models response is invalid ${JSON.stringify(result.error.format())}`)
44+
return models
8745
}
8846

89-
for (const model of data) {
47+
for (const model of result.data.data) {
9048
const { id } = model
9149

9250
// Only include language models for chat inference.
@@ -95,8 +53,7 @@ export async function getZooGatewayModels(options?: ApiHandlerOptions): Promise<
9553
continue
9654
}
9755

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

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

src/api/providers/zoo-gateway.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,15 @@ interface ZooGatewayUsage extends OpenAI.CompletionUsage {
2525
cost?: number
2626
}
2727

28+
const ZOO_GATEWAY_AUTH_ERROR = "Zoo Gateway requires authentication. Please sign in to Zoo Code first."
29+
2830
export class ZooGatewayHandler extends RouterProvider implements SingleCompletionHandler {
2931
constructor(options: ApiHandlerOptions) {
3032
const baseURL = options.zooGatewayBaseUrl ?? `${getZooCodeBaseUrl()}/api/gateway/v1`
3133

32-
// Prefer the profile-persisted token; fall back to the secret-storage cache so
33-
// requests work when the user is signed in but the profile hasn't been seeded yet
34-
// (e.g. auth callback arrived before any webview instance was open).
35-
const sessionToken = options.zooSessionToken || getCachedZooCodeToken()
36-
if (!sessionToken) {
37-
throw new Error("Zoo Gateway requires authentication. Please sign in to Zoo Code first.")
38-
}
34+
// Prefer the secret-storage cache so a 401 clear takes effect immediately; fall back
35+
// to the profile-persisted token when the user is signed in but seeding hasn't run yet.
36+
const sessionToken = getCachedZooCodeToken() || options.zooSessionToken
3937

4038
// Merge Zoo-specific enrichment headers into openAiHeaders so they flow through
4139
// the parent's single OpenAI client. We avoid reassigning `this.client` (which
@@ -52,18 +50,27 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio
5250
},
5351
name: "zoo-gateway",
5452
baseURL,
55-
apiKey: sessionToken,
53+
apiKey: sessionToken || "not-provided",
5654
modelId: options.zooGatewayModelId,
5755
defaultModelId: zooGatewayDefaultModelId,
5856
defaultModelInfo: zooGatewayDefaultModelInfo,
5957
})
6058
}
6159

60+
private ensureAuthenticated(): void {
61+
const sessionToken = getCachedZooCodeToken() || this.options.zooSessionToken
62+
if (!sessionToken) {
63+
throw new Error(ZOO_GATEWAY_AUTH_ERROR)
64+
}
65+
}
66+
6267
override async *createMessage(
6368
systemPrompt: string,
6469
messages: Anthropic.Messages.MessageParam[],
6570
metadata?: ApiHandlerCreateMessageMetadata,
6671
): ApiStream {
72+
this.ensureAuthenticated()
73+
6774
const { id: modelId, info } = await this.fetchModel()
6875

6976
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
@@ -141,6 +148,8 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio
141148
}
142149

143150
async completePrompt(prompt: string): Promise<string> {
151+
this.ensureAuthenticated()
152+
144153
const { id: modelId, info } = await this.fetchModel()
145154

146155
try {

webview-ui/src/components/settings/providers/ZooGateway.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { useExtensionState } from "@src/context/ExtensionStateContext"
1010
import { getZooCodeAuthUrl } from "@src/oauth/urls"
1111
import { useAppTranslation } from "@src/i18n/TranslationContext"
12+
import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
1213

1314
import { ModelPicker } from "../ModelPicker"
1415

@@ -92,11 +93,9 @@ export const ZooGateway = ({
9293
<p className="text-xs text-vscode-descriptionForeground">
9394
{t("settings:providers.zooGateway.signInDescription")}
9495
</p>
95-
<a
96-
href={authUrl}
97-
className="inline-flex w-fit items-center rounded-sm bg-vscode-button-background px-3 py-1 text-xs text-vscode-button-foreground no-underline hover:bg-vscode-button-hoverBackground">
96+
<VSCodeButtonLink href={authUrl} appearance="primary">
9897
{t("settings:providers.zooGateway.signInButton")}
99-
</a>
98+
</VSCodeButtonLink>
10099
</div>
101100
) : (
102101
<div className="flex items-center gap-1">

webview-ui/src/utils/__tests__/validate.spec.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,54 @@ describe("Model Validation Functions", () => {
211211
expect(result).toBe("settings:validation.modelId")
212212
})
213213
})
214+
215+
describe("Zoo Gateway validation", () => {
216+
it("returns a sign-in error when neither profile token nor Zoo auth is present", () => {
217+
const config: ProviderSettings = {
218+
apiProvider: "zoo-gateway",
219+
zooGatewayModelId: "anthropic/claude-sonnet-4",
220+
}
221+
222+
const result = validateApiConfigurationExcludingModelErrors(
223+
config,
224+
mockRouterModels,
225+
allowAllOrganization,
226+
false,
227+
)
228+
expect(result).toBe("settings:validation.zooGatewaySignIn")
229+
})
230+
231+
it("returns undefined when Zoo Code auth is active without a profile token", () => {
232+
const config: ProviderSettings = {
233+
apiProvider: "zoo-gateway",
234+
zooGatewayModelId: "anthropic/claude-sonnet-4",
235+
}
236+
237+
const result = validateApiConfigurationExcludingModelErrors(
238+
config,
239+
mockRouterModels,
240+
allowAllOrganization,
241+
true,
242+
)
243+
expect(result).toBeUndefined()
244+
})
245+
246+
it("returns undefined when a profile session token is set", () => {
247+
const config: ProviderSettings = {
248+
apiProvider: "zoo-gateway",
249+
zooGatewayModelId: "anthropic/claude-sonnet-4",
250+
zooSessionToken: "zoo_ext_test_token",
251+
}
252+
253+
const result = validateApiConfigurationExcludingModelErrors(
254+
config,
255+
mockRouterModels,
256+
allowAllOrganization,
257+
false,
258+
)
259+
expect(result).toBeUndefined()
260+
})
261+
})
214262
})
215263

216264
describe("validateBedrockArn", () => {

0 commit comments

Comments
 (0)