Skip to content

Commit f067b91

Browse files
James Mtendamemacursoragent
andcommitted
fix(zoo-gateway): defer auth check, fail-closed models, reuse Vercel schemas
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b9c8aa6 commit f067b91

4 files changed

Lines changed: 52 additions & 71 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
})

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

Lines changed: 16 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 { getZooCodeBaseUrl } from "../../../services/zoo-code-auth"
6+
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
@@ -63,28 +24,27 @@ 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
27+
// Build headers - Zoo Gateway requires authentication via the zoo_ext_ session token.
28+
// Fall back to the secret-storage cache when the profile hasn't been seeded yet.
29+
const sessionToken = options?.zooSessionToken || getCachedZooCodeToken()
6730
const headers: Record<string, string> = {}
68-
if (options?.zooSessionToken) {
69-
headers["Authorization"] = `Bearer ${options.zooSessionToken}`
31+
if (sessionToken) {
32+
headers["Authorization"] = `Bearer ${sessionToken}`
7033
}
7134

7235
try {
73-
const response = await axios.get<ZooGatewayModelsResponse>(`${baseURL}/models`, {
36+
const response = await axios.get(`${baseURL}/models`, {
7437
headers,
7538
timeout: MODEL_DISCOVERY_TIMEOUT_MS,
7639
})
77-
const result = zooGatewayModelsResponseSchema.safeParse(response.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 : []
40+
const result = vercelAiGatewayModelsResponseSchema.safeParse(response.data)
8241

8342
if (!result.success) {
8443
console.error(`Zoo Gateway models response is invalid ${JSON.stringify(result.error.format())}`)
44+
return models
8545
}
8646

87-
for (const model of data) {
47+
for (const model of result.data.data) {
8848
const { id } = model
8949

9050
// Only include language models for chat inference.
@@ -93,8 +53,7 @@ export async function getZooGatewayModels(options?: ApiHandlerOptions): Promise<
9353
continue
9454
}
9555

96-
// Parse model using the same logic as Vercel AI Gateway since formats are identical
97-
models[id] = parseZooGatewayModel({ id, model: model as VercelAiGatewayModel })
56+
models[id] = parseZooGatewayModel({ id, model })
9857
}
9958
} catch (error) {
10059
// Log only safe fields; never serialize the full error object because it
@@ -121,6 +80,5 @@ export async function getZooGatewayModels(options?: ApiHandlerOptions): Promise<
12180
*/
12281

12382
export const parseZooGatewayModel = ({ id, model }: { id: string; model: VercelAiGatewayModel }): ModelInfo => {
124-
// Reuse the parsing logic from vercel-ai-gateway
12583
return parseVercelAiGatewayModel({ id, model })
12684
}

src/api/providers/zoo-gateway.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from "@roo-code/types"
1010

1111
import { ApiHandlerOptions } from "../../shared/api"
12-
import { getZooCodeBaseUrl } from "../../services/zoo-code-auth"
12+
import { getCachedZooCodeToken, getZooCodeBaseUrl } from "../../services/zoo-code-auth"
1313
import { Package } from "../../shared/package"
1414

1515
import { ApiStream } from "../transform/stream"
@@ -25,16 +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-
// Fail fast with a clear message instead of waiting for a 401.
33-
// The token is set automatically by handleZooCodeCallback() after the user
34-
// authenticates via the "Sign in with Zoo Code" flow in the extension.
35-
if (!options.zooSessionToken) {
36-
throw new Error("Zoo Gateway requires authentication. Please sign in to Zoo Code first.")
37-
}
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
3837

3938
// Merge Zoo-specific enrichment headers into openAiHeaders so they flow through
4039
// the parent's single OpenAI client. We avoid reassigning `this.client` (which
@@ -51,18 +50,27 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio
5150
},
5251
name: "zoo-gateway",
5352
baseURL,
54-
apiKey: options.zooSessionToken,
53+
apiKey: sessionToken || "not-provided",
5554
modelId: options.zooGatewayModelId,
5655
defaultModelId: zooGatewayDefaultModelId,
5756
defaultModelInfo: zooGatewayDefaultModelInfo,
5857
})
5958
}
6059

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+
6167
override async *createMessage(
6268
systemPrompt: string,
6369
messages: Anthropic.Messages.MessageParam[],
6470
metadata?: ApiHandlerCreateMessageMetadata,
6571
): ApiStream {
72+
this.ensureAuthenticated()
73+
6674
const { id: modelId, info } = await this.fetchModel()
6775

6876
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
@@ -140,6 +148,8 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio
140148
}
141149

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

145155
try {

0 commit comments

Comments
 (0)