Skip to content

Commit 5b7e18f

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

12 files changed

Lines changed: 394 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",
@@ -405,6 +406,12 @@ const opencodeGoSchema = baseProviderSettingsSchema.extend({
405406
opencodeGoModelId: z.string().optional(),
406407
})
407408

409+
const zooGatewaySchema = baseProviderSettingsSchema.extend({
410+
zooSessionToken: z.string().optional(),
411+
zooGatewayModelId: z.string().optional(),
412+
zooGatewayBaseUrl: z.string().optional(),
413+
})
414+
408415
const basetenSchema = apiModelIdProviderModelSchema.extend({
409416
basetenApiKey: z.string().optional(),
410417
})
@@ -444,6 +451,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
444451
qwenCodeSchema.merge(z.object({ apiProvider: z.literal("qwen-code") })),
445452
vercelAiGatewaySchema.merge(z.object({ apiProvider: z.literal("vercel-ai-gateway") })),
446453
opencodeGoSchema.merge(z.object({ apiProvider: z.literal("opencode-go") })),
454+
zooGatewaySchema.merge(z.object({ apiProvider: z.literal("zoo-gateway") })),
447455
defaultSchema,
448456
])
449457

@@ -479,6 +487,7 @@ export const providerSettingsSchema = z.object({
479487
...qwenCodeSchema.shape,
480488
...vercelAiGatewaySchema.shape,
481489
...opencodeGoSchema.shape,
490+
...zooGatewaySchema.shape,
482491
...codebaseIndexProviderSchema.shape,
483492
})
484493

@@ -510,6 +519,7 @@ export const modelIdKeys = [
510519
"litellmModelId",
511520
"vercelAiGatewayModelId",
512521
"opencodeGoModelId",
522+
"zooGatewayModelId",
513523
] as const satisfies readonly (keyof ProviderSettings)[]
514524

515525
export type ModelIdKey = (typeof modelIdKeys)[number]
@@ -556,6 +566,7 @@ export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
556566
fireworks: "apiModelId",
557567
"vercel-ai-gateway": "vercelAiGatewayModelId",
558568
"opencode-go": "opencodeGoModelId",
569+
"zoo-gateway": "zooGatewayModelId",
559570
}
560571

561572
/**
@@ -574,8 +585,13 @@ export const getApiProtocol = (provider: ProviderName | undefined, modelId?: str
574585
return "anthropic"
575586
}
576587

577-
// Vercel AI Gateway uses anthropic protocol for anthropic models.
578-
if (provider && provider === "vercel-ai-gateway" && modelId && modelId.toLowerCase().startsWith("anthropic/")) {
588+
// Vercel AI Gateway, Zoo Gateway, and Roo use anthropic protocol for anthropic models.
589+
if (
590+
provider &&
591+
["vercel-ai-gateway", "zoo-gateway", "roo"].includes(provider) &&
592+
modelId &&
593+
modelId.toLowerCase().startsWith("anthropic/")
594+
) {
579595
return "anthropic"
580596
}
581597

@@ -673,6 +689,7 @@ export const MODELS_BY_PROVIDER: Record<
673689
unbound: { id: "unbound", label: "Unbound", models: [] },
674690
"vercel-ai-gateway": { id: "vercel-ai-gateway", label: "Vercel AI Gateway", models: [] },
675691
"opencode-go": { id: "opencode-go", label: "Opencode Go", models: [] },
692+
"zoo-gateway": { id: "zoo-gateway", label: "Zoo Gateway", models: [] },
676693

677694
// Local providers; models discovered from localhost endpoints.
678695
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
@@ -26,6 +26,7 @@ export * from "./opencode-go.js"
2626
export * from "./zai.js"
2727
export * from "./minimax.js"
2828
export * from "./mimo.js"
29+
export * from "./zoo-gateway.js"
2930

3031
import { anthropicDefaultModelId } from "./anthropic.js"
3132
import { basetenDefaultModelId } from "./baseten.js"
@@ -51,6 +52,7 @@ import { opencodeGoDefaultModelId } from "./opencode-go.js"
5152
import { internationalZAiDefaultModelId, mainlandZAiDefaultModelId } from "./zai.js"
5253
import { minimaxDefaultModelId } from "./minimax.js"
5354
import { mimoDefaultModelId } from "./mimo.js"
55+
import { zooGatewayDefaultModelId } from "./zoo-gateway.js"
5456

5557
// Import the ProviderName type from provider-settings to avoid duplication
5658
import type { ProviderName } from "../provider-settings.js"
@@ -119,6 +121,8 @@ export function getProviderDefaultModelId(
119121
return vercelAiGatewayDefaultModelId
120122
case "opencode-go":
121123
return opencodeGoDefaultModelId
124+
case "zoo-gateway":
125+
return zooGatewayDefaultModelId
122126
case "anthropic":
123127
case "gemini-cli":
124128
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
@@ -33,6 +33,7 @@ import {
3333
FireworksHandler,
3434
VercelAiGatewayHandler,
3535
OpencodeGoHandler,
36+
ZooGatewayHandler,
3637
MiniMaxHandler,
3738
MimoHandler,
3839
BasetenHandler,
@@ -179,6 +180,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
179180
return new VercelAiGatewayHandler(options)
180181
case "opencode-go":
181182
return new OpencodeGoHandler(options)
183+
case "zoo-gateway":
184+
return new ZooGatewayHandler(options)
182185
case "minimax":
183186
return new MiniMaxHandler(options)
184187
case "baseten":

src/api/providers/fetchers/modelCache.ts

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

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

@@ -96,6 +97,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise<Model
9697
case "deepseek":
9798
models = await getDeepSeekModels(options.baseUrl, options.apiKey)
9899
break
100+
case "zoo-gateway":
101+
models = await getZooGatewayModels({ zooSessionToken: options.apiKey, zooGatewayBaseUrl: options.baseUrl })
102+
break
99103
default: {
100104
// Ensures router is exhaustively checked if RouterName is a strict union.
101105
const exhaustiveCheck: never = provider
@@ -120,7 +124,10 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise<Model
120124
export const getModels = async (options: GetModelsOptions): Promise<ModelRecord> => {
121125
const { provider } = options
122126

123-
let models = getModelsFromCache(provider)
127+
// Always fetch fresh to prevent serving stale models from different auth contexts.
128+
const shouldSkipCache = provider === "zoo-gateway"
129+
130+
let models = shouldSkipCache ? undefined : getModelsFromCache(provider)
124131

125132
if (models) {
126133
return models
@@ -132,13 +139,14 @@ export const getModels = async (options: GetModelsOptions): Promise<ModelRecord>
132139

133140
// Only cache non-empty results to prevent persisting failed API responses
134141
// Empty results could indicate API failure rather than "no models exist"
135-
if (modelCount > 0) {
142+
// Zoo Gateway models are user-specific - skip caching entirely
143+
if (modelCount > 0 && !shouldSkipCache) {
136144
memoryCache.set(provider, models)
137145

138146
await writeModels(provider, models).catch((err) =>
139147
console.error(`[MODEL_CACHE] Error writing ${provider} models to file cache:`, err),
140148
)
141-
} else {
149+
} else if (modelCount === 0) {
142150
TelemetryService.instance.captureEvent(TelemetryEventName.MODEL_CACHE_EMPTY_RESPONSE, {
143151
provider,
144152
context: "getModels",
@@ -167,6 +175,11 @@ export const getModels = async (options: GetModelsOptions): Promise<ModelRecord>
167175
export const refreshModels = async (options: GetModelsOptions): Promise<ModelRecord> => {
168176
const { provider } = options
169177

178+
// Zoo Gateway models are user-specific (auth-scoped). Mirror the bypass in
179+
// getModels() so we never persist one user's model list and serve it to a
180+
// different authenticated user from cache.
181+
const shouldSkipCache = provider === "zoo-gateway"
182+
170183
// Check if there's already an in-flight refresh for this provider
171184
// This prevents race conditions where multiple concurrent refreshes might
172185
// overwrite each other's results
@@ -183,7 +196,7 @@ export const refreshModels = async (options: GetModelsOptions): Promise<ModelRec
183196
const modelCount = Object.keys(models).length
184197

185198
// Get existing cached data for comparison
186-
const existingCache = getModelsFromCache(provider)
199+
const existingCache = shouldSkipCache ? undefined : getModelsFromCache(provider)
187200
const existingCount = existingCache ? Object.keys(existingCache).length : 0
188201

189202
if (modelCount === 0) {
@@ -200,18 +213,23 @@ export const refreshModels = async (options: GetModelsOptions): Promise<ModelRec
200213
}
201214
}
202215

203-
// Update memory cache first
204-
memoryCache.set(provider, models)
216+
if (!shouldSkipCache) {
217+
memoryCache.set(provider, models)
205218

206-
// Atomically write to disk (safeWriteJson handles atomic writes)
207-
await writeModels(provider, models).catch((err) =>
208-
console.error(`[refreshModels] Error writing ${provider} models to disk:`, err),
209-
)
219+
await writeModels(provider, models).catch((err) =>
220+
console.error(`[refreshModels] Error writing ${provider} models to disk:`, err),
221+
)
222+
}
210223

211224
return models
212225
} catch (error) {
213-
// Log the error for debugging, then return existing cache if available (graceful degradation)
226+
// Log the error for debugging, then return existing cache if available (graceful degradation).
227+
// For auth-scoped providers (zoo-gateway) we MUST NOT return cached models from a prior
228+
// session, since they could belong to a different user — return empty instead.
214229
console.error(`[refreshModels] Failed to refresh ${provider} models:`, error)
230+
if (shouldSkipCache) {
231+
return {}
232+
}
215233
return getModelsFromCache(provider) || {}
216234
} finally {
217235
// 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
@@ -26,6 +26,7 @@ export { ZAiHandler } from "./zai"
2626
export { FireworksHandler } from "./fireworks"
2727
export { VercelAiGatewayHandler } from "./vercel-ai-gateway"
2828
export { OpencodeGoHandler } from "./opencode-go"
29+
export { ZooGatewayHandler } from "./zoo-gateway"
2930
export { MiniMaxHandler } from "./minimax"
3031
export { MimoHandler } from "./mimo"
3132
export { BasetenHandler } from "./baseten"

0 commit comments

Comments
 (0)