Skip to content

Commit f776483

Browse files
James Mtendamemacursoragent
andcommitted
feat(zoo-gateway): add provider types, handler, and model fetcher
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 59b035f commit f776483

9 files changed

Lines changed: 383 additions & 13 deletions

File tree

packages/types/src/provider-settings.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export const DEFAULT_CONSECUTIVE_MISTAKE_LIMIT = 3
3838
export const dynamicProviders = [
3939
"openrouter",
4040
"vercel-ai-gateway",
41+
"zoo-gateway",
4142
"litellm",
4243
"requesty",
4344
"unbound",
@@ -399,6 +400,12 @@ const vercelAiGatewaySchema = baseProviderSettingsSchema.extend({
399400
vercelAiGatewayModelId: z.string().optional(),
400401
})
401402

403+
const zooGatewaySchema = baseProviderSettingsSchema.extend({
404+
zooSessionToken: z.string().optional(),
405+
zooGatewayModelId: z.string().optional(),
406+
zooGatewayBaseUrl: z.string().optional(),
407+
})
408+
402409
const basetenSchema = apiModelIdProviderModelSchema.extend({
403410
basetenApiKey: z.string().optional(),
404411
})
@@ -437,6 +444,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
437444
fireworksSchema.merge(z.object({ apiProvider: z.literal("fireworks") })),
438445
qwenCodeSchema.merge(z.object({ apiProvider: z.literal("qwen-code") })),
439446
vercelAiGatewaySchema.merge(z.object({ apiProvider: z.literal("vercel-ai-gateway") })),
447+
zooGatewaySchema.merge(z.object({ apiProvider: z.literal("zoo-gateway") })),
440448
defaultSchema,
441449
])
442450

@@ -471,6 +479,7 @@ export const providerSettingsSchema = z.object({
471479
...fireworksSchema.shape,
472480
...qwenCodeSchema.shape,
473481
...vercelAiGatewaySchema.shape,
482+
...zooGatewaySchema.shape,
474483
...codebaseIndexProviderSchema.shape,
475484
})
476485

@@ -501,6 +510,7 @@ export const modelIdKeys = [
501510
"unboundModelId",
502511
"litellmModelId",
503512
"vercelAiGatewayModelId",
513+
"zooGatewayModelId",
504514
] as const satisfies readonly (keyof ProviderSettings)[]
505515

506516
export type ModelIdKey = (typeof modelIdKeys)[number]
@@ -546,6 +556,7 @@ export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
546556
zai: "apiModelId",
547557
fireworks: "apiModelId",
548558
"vercel-ai-gateway": "vercelAiGatewayModelId",
559+
"zoo-gateway": "zooGatewayModelId",
549560
}
550561

551562
/**
@@ -564,8 +575,13 @@ export const getApiProtocol = (provider: ProviderName | undefined, modelId?: str
564575
return "anthropic"
565576
}
566577

567-
// Vercel AI Gateway uses anthropic protocol for anthropic models.
568-
if (provider && provider === "vercel-ai-gateway" && modelId && modelId.toLowerCase().startsWith("anthropic/")) {
578+
// Vercel AI Gateway, Zoo Gateway, and Roo use anthropic protocol for anthropic models.
579+
if (
580+
provider &&
581+
["vercel-ai-gateway", "zoo-gateway", "roo"].includes(provider) &&
582+
modelId &&
583+
modelId.toLowerCase().startsWith("anthropic/")
584+
) {
569585
return "anthropic"
570586
}
571587

@@ -662,6 +678,7 @@ export const MODELS_BY_PROVIDER: Record<
662678
requesty: { id: "requesty", label: "Requesty", models: [] },
663679
unbound: { id: "unbound", label: "Unbound", models: [] },
664680
"vercel-ai-gateway": { id: "vercel-ai-gateway", label: "Vercel AI Gateway", models: [] },
681+
"zoo-gateway": { id: "zoo-gateway", label: "Zoo Gateway", models: [] },
665682

666683
// Local providers; models discovered from localhost endpoints.
667684
lmstudio: { id: "lmstudio", label: "LM Studio", models: [] },

packages/types/src/providers/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export * from "./vercel-ai-gateway.js"
2525
export * from "./zai.js"
2626
export * from "./minimax.js"
2727
export * from "./mimo.js"
28+
export * from "./zoo-gateway.js"
2829

2930
import { anthropicDefaultModelId } from "./anthropic.js"
3031
import { basetenDefaultModelId } from "./baseten.js"
@@ -49,6 +50,7 @@ import { vercelAiGatewayDefaultModelId } from "./vercel-ai-gateway.js"
4950
import { internationalZAiDefaultModelId, mainlandZAiDefaultModelId } from "./zai.js"
5051
import { minimaxDefaultModelId } from "./minimax.js"
5152
import { mimoDefaultModelId } from "./mimo.js"
53+
import { zooGatewayDefaultModelId } from "./zoo-gateway.js"
5254

5355
// Import the ProviderName type from provider-settings to avoid duplication
5456
import type { ProviderName } from "../provider-settings.js"
@@ -115,6 +117,8 @@ export function getProviderDefaultModelId(
115117
return unboundDefaultModelId
116118
case "vercel-ai-gateway":
117119
return vercelAiGatewayDefaultModelId
120+
case "zoo-gateway":
121+
return zooGatewayDefaultModelId
118122
case "anthropic":
119123
case "gemini-cli":
120124
case "fake-ai":
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { ModelInfo } from "../model.js"
2+
3+
// Zoo Gateway uses the same model ID format as Vercel AI Gateway (provider/model-name)
4+
export const zooGatewayDefaultModelId = "anthropic/claude-sonnet-4"
5+
6+
// Zoo Gateway serves the same models as Vercel AI Gateway, so prompt caching support is identical
7+
// We reuse VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS from vercel-ai-gateway.ts
8+
// Instead of duplicating, we just export a reference to indicate they're the same
9+
export { VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS as ZOO_GATEWAY_PROMPT_CACHING_MODELS } from "./vercel-ai-gateway.js"
10+
11+
export const zooGatewayDefaultModelInfo: ModelInfo = {
12+
maxTokens: 64000,
13+
contextWindow: 200000,
14+
supportsImages: true,
15+
supportsPromptCache: true,
16+
inputPrice: 3,
17+
outputPrice: 15,
18+
cacheWritesPrice: 3.75,
19+
cacheReadsPrice: 0.3,
20+
description:
21+
"Claude Sonnet 4 significantly improves on Sonnet 3.7's industry-leading capabilities, excelling in coding with a state-of-the-art 72.7% on SWE-bench. The model balances performance and efficiency for internal and external use cases, with enhanced steerability for greater control over implementations.",
22+
}
23+
24+
export const ZOO_GATEWAY_DEFAULT_TEMPERATURE = 0.7

src/api/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
ZAiHandler,
3333
FireworksHandler,
3434
VercelAiGatewayHandler,
35+
ZooGatewayHandler,
3536
MiniMaxHandler,
3637
MimoHandler,
3738
BasetenHandler,
@@ -176,6 +177,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
176177
return new FireworksHandler(options)
177178
case "vercel-ai-gateway":
178179
return new VercelAiGatewayHandler(options)
180+
case "zoo-gateway":
181+
return new ZooGatewayHandler(options)
179182
case "minimax":
180183
return new MiniMaxHandler(options)
181184
case "baseten":

src/api/providers/fetchers/modelCache.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { getOllamaModels } from "./ollama"
2626
import { getLMStudioModels } from "./lmstudio"
2727
import { getPoeModels } from "./poe"
2828
import { getDeepSeekModels } from "./deepseek"
29+
import { getZooGatewayModels } from "./zoo-gateway"
2930

3031
const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 })
3132

@@ -92,6 +93,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise<Model
9293
case "deepseek":
9394
models = await getDeepSeekModels(options.baseUrl, options.apiKey)
9495
break
96+
case "zoo-gateway":
97+
models = await getZooGatewayModels({ zooSessionToken: options.apiKey, zooGatewayBaseUrl: options.baseUrl })
98+
break
9599
default: {
96100
// Ensures router is exhaustively checked if RouterName is a strict union.
97101
const exhaustiveCheck: never = provider
@@ -116,7 +120,10 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise<Model
116120
export const getModels = async (options: GetModelsOptions): Promise<ModelRecord> => {
117121
const { provider } = options
118122

119-
let models = getModelsFromCache(provider)
123+
// Always fetch fresh to prevent serving stale models from different auth contexts.
124+
const shouldSkipCache = provider === "zoo-gateway"
125+
126+
let models = shouldSkipCache ? undefined : getModelsFromCache(provider)
120127

121128
if (models) {
122129
return models
@@ -128,13 +135,14 @@ export const getModels = async (options: GetModelsOptions): Promise<ModelRecord>
128135

129136
// Only cache non-empty results to prevent persisting failed API responses
130137
// Empty results could indicate API failure rather than "no models exist"
131-
if (modelCount > 0) {
138+
// Zoo Gateway models are user-specific - skip caching entirely
139+
if (modelCount > 0 && !shouldSkipCache) {
132140
memoryCache.set(provider, models)
133141

134142
await writeModels(provider, models).catch((err) =>
135143
console.error(`[MODEL_CACHE] Error writing ${provider} models to file cache:`, err),
136144
)
137-
} else {
145+
} else if (modelCount === 0) {
138146
TelemetryService.instance.captureEvent(TelemetryEventName.MODEL_CACHE_EMPTY_RESPONSE, {
139147
provider,
140148
context: "getModels",
@@ -163,6 +171,11 @@ export const getModels = async (options: GetModelsOptions): Promise<ModelRecord>
163171
export const refreshModels = async (options: GetModelsOptions): Promise<ModelRecord> => {
164172
const { provider } = options
165173

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+
166179
// Check if there's already an in-flight refresh for this provider
167180
// This prevents race conditions where multiple concurrent refreshes might
168181
// overwrite each other's results
@@ -179,7 +192,7 @@ export const refreshModels = async (options: GetModelsOptions): Promise<ModelRec
179192
const modelCount = Object.keys(models).length
180193

181194
// Get existing cached data for comparison
182-
const existingCache = getModelsFromCache(provider)
195+
const existingCache = shouldSkipCache ? undefined : getModelsFromCache(provider)
183196
const existingCount = existingCache ? Object.keys(existingCache).length : 0
184197

185198
if (modelCount === 0) {
@@ -196,18 +209,23 @@ export const refreshModels = async (options: GetModelsOptions): Promise<ModelRec
196209
}
197210
}
198211

199-
// Update memory cache first
200-
memoryCache.set(provider, models)
212+
if (!shouldSkipCache) {
213+
memoryCache.set(provider, models)
201214

202-
// Atomically write to disk (safeWriteJson handles atomic writes)
203-
await writeModels(provider, models).catch((err) =>
204-
console.error(`[refreshModels] Error writing ${provider} models to disk:`, err),
205-
)
215+
await writeModels(provider, models).catch((err) =>
216+
console.error(`[refreshModels] Error writing ${provider} models to disk:`, err),
217+
)
218+
}
206219

207220
return models
208221
} catch (error) {
209-
// 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.
210225
console.error(`[refreshModels] Failed to refresh ${provider} models:`, error)
226+
if (shouldSkipCache) {
227+
return {}
228+
}
211229
return getModelsFromCache(provider) || {}
212230
} finally {
213231
// Always clean up the in-flight tracking
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import axios from "axios"
2+
3+
import type { ModelInfo } from "@roo-code/types"
4+
5+
import type { ApiHandlerOptions } from "../../../shared/api"
6+
import { getZooCodeBaseUrl } from "../../../services/zoo-code-auth"
7+
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>
52+
53+
// Bound model discovery so a network stall can't hang provider initialization paths.
54+
const MODEL_DISCOVERY_TIMEOUT_MS = 15_000
55+
56+
/**
57+
* getZooGatewayModels
58+
*
59+
* Fetches models from the Zoo Gateway API. Requires authentication via the zoo_ext_ token.
60+
*/
61+
62+
export async function getZooGatewayModels(options?: ApiHandlerOptions): Promise<Record<string, ModelInfo>> {
63+
const models: Record<string, ModelInfo> = {}
64+
const baseURL = options?.zooGatewayBaseUrl ?? `${getZooCodeBaseUrl()}/api/gateway/v1`
65+
66+
// Build headers - Zoo Gateway requires authentication via the zoo_ext_ session token
67+
const headers: Record<string, string> = {}
68+
if (options?.zooSessionToken) {
69+
headers["Authorization"] = `Bearer ${options.zooSessionToken}`
70+
}
71+
72+
try {
73+
const response = await axios.get<ZooGatewayModelsResponse>(`${baseURL}/models`, {
74+
headers,
75+
timeout: MODEL_DISCOVERY_TIMEOUT_MS,
76+
})
77+
const result = zooGatewayModelsResponseSchema.safeParse(response.data)
78+
const data = result.success ? result.data.data : response.data.data
79+
80+
if (!result.success) {
81+
console.error(`Zoo Gateway models response is invalid ${JSON.stringify(result.error.format())}`)
82+
}
83+
84+
for (const model of data) {
85+
const { id } = model
86+
87+
// Only include language models for chat inference.
88+
// Embedding models are statically defined in embeddingModels.ts.
89+
if (model.type !== "language") {
90+
continue
91+
}
92+
93+
// Parse model using the same logic as Vercel AI Gateway since formats are identical
94+
models[id] = parseZooGatewayModel({ id, model: model as VercelAiGatewayModel })
95+
}
96+
} 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+
}
105+
console.error(
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"}`,
107+
)
108+
}
109+
110+
return models
111+
}
112+
113+
/**
114+
* parseZooGatewayModel
115+
*
116+
* Parses a Zoo Gateway model into ModelInfo format.
117+
* Zoo Gateway returns the same format as Vercel AI Gateway, so we can reuse the parsing logic.
118+
*/
119+
120+
export const parseZooGatewayModel = ({ id, model }: { id: string; model: VercelAiGatewayModel }): ModelInfo => {
121+
// Reuse the parsing logic from vercel-ai-gateway
122+
return parseVercelAiGatewayModel({ id, model })
123+
}

src/api/providers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export { XAIHandler } from "./xai"
2525
export { ZAiHandler } from "./zai"
2626
export { FireworksHandler } from "./fireworks"
2727
export { VercelAiGatewayHandler } from "./vercel-ai-gateway"
28+
export { ZooGatewayHandler } from "./zoo-gateway"
2829
export { MiniMaxHandler } from "./minimax"
2930
export { MimoHandler } from "./mimo"
3031
export { BasetenHandler } from "./baseten"

0 commit comments

Comments
 (0)