Skip to content

Commit f9c9c09

Browse files
JamesRobert20James Mtendamemacursoragent
authored
feat(zoo-gateway): settings UI, validation, and i18n (#345)
* feat(zoo-gateway): add provider types, handler, and model fetcher Co-authored-by: Cursor <cursoragent@cursor.com> * fix(zoo-gateway): respect readonly client, real version header, safer fetch - Stop reassigning RouterProvider.client; thread Zoo enrichment headers through openAiHeaders so a single OpenAI client is used. - Replace npm_package_version (never populated at extension runtime) with Package.version from the shared package shim. - Default the model list to [] on a structurally broken response so we log and recover instead of crashing on response.data.data being undefined. - Bypass inFlightRefresh de-duplication for zoo-gateway: a refresh triggered after sign-out/sign-in must not return the previous user's in-flight response. - Add fetcher unit tests covering auth header, timeout, error redaction, and bad-response handling. Co-authored-by: Cursor <cursoragent@cursor.com> * test(zoo-gateway): mock cached-token + clear-token auth helpers The downstream stack (settings-ui) calls getCachedZooCodeToken and clearZooCodeToken from the auth handler. CI on stacked PRs merges base into head so this spec runs against the cached-token-aware handler; expand the auth module mock so the auth guard test exercises the real throw path instead of vitest's missing-mock-export error. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(zoo-gateway): add settings UI, validation, and i18n Co-authored-by: Cursor <cursoragent@cursor.com> * fix(zoo-gateway): dynamic dashboard URL and cached-token fallback - Resolve ModelPicker serviceUrl from zooCodeBaseUrl so staging/dev environments link to the matching dashboard. - Fall back to getCachedZooCodeToken() in the handler and model fetcher when the profile has not been seeded yet (auth before webview open). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(deps): drop stale webview-ui package.json drift that broke frozen-lockfile installs Co-authored-by: Cursor <cursoragent@cursor.com> * fix(i18n): restore googleCloudCredentialsPathWarning and automaticFetch 'free' search hint dropped on rebase Co-authored-by: Cursor <cursoragent@cursor.com> * fix(zoo-gateway): pick default model from fetched list, prefer Sonnet 4.5 Resolves Sonnet 4.5 from the gateway model catalog instead of a static Vercel slug so test (Bedrock) and live accounts both get a valid default. Reassigns stale profile model IDs when they are not in the catalog. Co-authored-by: Cursor <cursoragent@cursor.com> * test(zoo-gateway): cover dynamic default model picker for codecov patch Exports pickZooGatewayDefaultModelId so the helper is unit-testable and adds component tests for the auto-default useEffect (no-op while catalog loads, auto-pick on empty profile, repair stale id, no-op when valid). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(i18n): restore UTF-8 for Google Cloud warning and model picker strings Co-authored-by: Cursor <cursoragent@cursor.com> * fix(zoo-gateway): settings UI sign-in button, validation tests, defer auth Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(zoo-gateway): localize auth-state UX to provider component Move the zoo-gateway sign-in error out of the shared form-validation effect in ApiOptions and into ZooGateway.tsx, where it renders inline via ApiErrorMessage. ApiOptions can then drop zooCodeIsAuthenticated from its useEffect dependency list, and validateApiConfigurationExcludingModelErrors short-circuits zoo-gateway entirely. Also rename the inline isSonnet45ModelId helper to isClaudeSonnetModelId and let pickZooGatewayDefaultModelId express the version-priority order directly, so the helper has no version baked into its name. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(zoo-gateway): enforce org provider check, simplify sign-in copy The settings-form short-circuit for zoo-gateway also bypassed the organization PROVIDER_NOT_ALLOWED check, so a workspace that disallows zoo-gateway could not surface that error. Scope the short-circuit to the keys/sign-in check only and let the org allowlist check run for every provider. Drop the quoted CTA from zooGatewaySignIn in all 18 locales: the sign-in button is rendered immediately below the inline error, so a duplicate label was just a drift hazard. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(webview): move provider model config + docs slugs out of ApiOptions Lift the inline PROVIDER_MODEL_CONFIG map and the docs-slug lookup out of ApiOptions.tsx into webview-ui/src/components/settings/utils/providerModelConfig.ts behind getProviderModelConfig and getProviderDocsSlug helpers. ApiOptions now reads provider-specific model fields, defaults, and docs slugs through those helpers, leaving the component focused on rendering. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(i18n): align French zooGatewaySignIn to formal vous register validation.zooGatewaySignIn used "Connecte-toi" while providers.zooGateway.signInDescription uses "Connectez-vous". Use vous consistently across both strings. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: James Mtendamema <jmtendamema@geologicai.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 99c7ed1 commit f9c9c09

30 files changed

Lines changed: 731 additions & 111 deletions

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import axios from "axios"
55
import { getZooGatewayModels, parseZooGatewayModel } from "../zoo-gateway"
66

77
vitest.mock("axios")
8+
vitest.mock("../../../../services/zoo-code-auth", () => ({
9+
getCachedZooCodeToken: vitest.fn(() => ""),
10+
getZooCodeBaseUrl: vitest.fn(() => "https://example.test"),
11+
}))
812
const mockedAxios = axios as any
913

1014
describe("Zoo Gateway Fetchers", () => {
@@ -134,7 +138,6 @@ describe("Zoo Gateway Fetchers", () => {
134138
expect(Object.keys(models)).toEqual(["anthropic/claude-sonnet-4"])
135139
expect(models["anthropic/claude-sonnet-4"].description).toBe("Claude Sonnet 4")
136140
})
137-
138141
it("returns {} on a structurally broken response instead of throwing", async () => {
139142
const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(() => {})
140143
mockedAxios.get.mockResolvedValueOnce({ data: { unexpected: true } })

webview-ui/src/components/settings/ApiOptions.tsx

Lines changed: 26 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -9,36 +9,13 @@ import {
99
type ProviderSettings,
1010
isRetiredProvider,
1111
DEFAULT_CONSECUTIVE_MISTAKE_LIMIT,
12-
openRouterDefaultModelId,
13-
poeDefaultModelId,
14-
requestyDefaultModelId,
15-
litellmDefaultModelId,
16-
openAiNativeDefaultModelId,
17-
openAiCodexDefaultModelId,
18-
anthropicDefaultModelId,
19-
qwenCodeDefaultModelId,
20-
geminiDefaultModelId,
21-
deepSeekDefaultModelId,
22-
moonshotDefaultModelId,
23-
mistralDefaultModelId,
24-
xaiDefaultModelId,
25-
basetenDefaultModelId,
26-
bedrockDefaultModelId,
27-
vertexDefaultModelId,
28-
sambaNovaDefaultModelId,
29-
internationalZAiDefaultModelId,
30-
mainlandZAiDefaultModelId,
31-
fireworksDefaultModelId,
32-
vercelAiGatewayDefaultModelId,
33-
opencodeGoDefaultModelId,
34-
minimaxDefaultModelId,
35-
mimoDefaultModelId,
36-
unboundDefaultModelId,
3712
} from "@roo-code/types"
3813

3914
import {
4015
getProviderServiceConfig,
4116
getDefaultModelIdForProvider,
17+
getProviderDocsSlug,
18+
getProviderModelConfig,
4219
getStaticModelsForProvider,
4320
shouldUseGenericModelPicker,
4421
handleModelChangeSideEffects,
@@ -95,6 +72,7 @@ import {
9572
Fireworks,
9673
VercelAiGateway,
9774
OpenCodeGo,
75+
ZooGateway,
9876
MiniMax,
9977
Mimo,
10078
} from "./providers"
@@ -268,6 +246,14 @@ const ApiOptions = ({
268246
return
269247
}
270248

249+
// Zoo Gateway renders its own auth-state error inline (sign-in card in
250+
// ZooGateway.tsx) so it can react to zooCodeIsAuthenticated changes
251+
// without re-running this effect or threading auth state through validation.
252+
if (apiConfiguration.apiProvider === "zoo-gateway") {
253+
setErrorMessage(undefined)
254+
return
255+
}
256+
271257
const apiValidationResult = validateApiConfigurationExcludingModelErrors(
272258
apiConfiguration,
273259
routerModels,
@@ -326,52 +312,7 @@ const ApiOptions = ({
326312
}
327313
}
328314

329-
// Define a mapping object that associates each provider with its model configuration
330-
const PROVIDER_MODEL_CONFIG: Partial<
331-
Record<
332-
ProviderName,
333-
{
334-
field: keyof ProviderSettings
335-
default?: string
336-
}
337-
>
338-
> = {
339-
openrouter: { field: "openRouterModelId", default: openRouterDefaultModelId },
340-
requesty: { field: "requestyModelId", default: requestyDefaultModelId },
341-
unbound: { field: "unboundModelId", default: unboundDefaultModelId },
342-
litellm: { field: "litellmModelId", default: litellmDefaultModelId },
343-
anthropic: { field: "apiModelId", default: anthropicDefaultModelId },
344-
"openai-codex": { field: "apiModelId", default: openAiCodexDefaultModelId },
345-
"qwen-code": { field: "apiModelId", default: qwenCodeDefaultModelId },
346-
"openai-native": { field: "apiModelId", default: openAiNativeDefaultModelId },
347-
gemini: { field: "apiModelId", default: geminiDefaultModelId },
348-
deepseek: { field: "apiModelId", default: deepSeekDefaultModelId },
349-
moonshot: { field: "apiModelId", default: moonshotDefaultModelId },
350-
minimax: { field: "apiModelId", default: minimaxDefaultModelId },
351-
mimo: { field: "apiModelId", default: mimoDefaultModelId },
352-
mistral: { field: "apiModelId", default: mistralDefaultModelId },
353-
xai: { field: "apiModelId", default: xaiDefaultModelId },
354-
baseten: { field: "apiModelId", default: basetenDefaultModelId },
355-
bedrock: { field: "apiModelId", default: bedrockDefaultModelId },
356-
vertex: { field: "apiModelId", default: vertexDefaultModelId },
357-
sambanova: { field: "apiModelId", default: sambaNovaDefaultModelId },
358-
zai: {
359-
field: "apiModelId",
360-
default:
361-
apiConfiguration.zaiApiLine === "china_coding"
362-
? mainlandZAiDefaultModelId
363-
: internationalZAiDefaultModelId,
364-
},
365-
fireworks: { field: "apiModelId", default: fireworksDefaultModelId },
366-
poe: { field: "apiModelId", default: poeDefaultModelId },
367-
"vercel-ai-gateway": { field: "vercelAiGatewayModelId", default: vercelAiGatewayDefaultModelId },
368-
"opencode-go": { field: "opencodeGoModelId", default: opencodeGoDefaultModelId },
369-
openai: { field: "openAiModelId" },
370-
ollama: { field: "ollamaModelId" },
371-
lmstudio: { field: "lmStudioModelId" },
372-
}
373-
374-
const config = PROVIDER_MODEL_CONFIG[value]
315+
const config = getProviderModelConfig(value, apiConfiguration)
375316
if (config) {
376317
validateAndResetModel(
377318
value,
@@ -390,22 +331,14 @@ const ApiOptions = ({
390331

391332
const docs = useMemo(() => {
392333
const provider = PROVIDERS.find(({ value }) => value === selectedProvider)
393-
const name = provider?.label
394-
395-
if (!name) {
334+
if (!provider) {
396335
return undefined
397336
}
398337

399-
// Get the URL slug - use custom mapping if available, otherwise use the provider key.
400-
const slugs: Record<string, string> = {
401-
"openai-native": "openai",
402-
openai: "openai-compatible",
403-
}
404-
405-
const slug = slugs[selectedProvider] || selectedProvider
338+
const slug = getProviderDocsSlug(provider.value)
406339
return {
407340
url: buildDocLink(`providers/${slug}`, "provider_docs"),
408-
name,
341+
name: provider.label,
409342
}
410343
}, [selectedProvider])
411344

@@ -702,6 +635,17 @@ const ApiOptions = ({
702635
/>
703636
)}
704637

638+
{selectedProvider === "zoo-gateway" && (
639+
<ZooGateway
640+
apiConfiguration={apiConfiguration}
641+
setApiConfigurationField={setApiConfigurationField}
642+
routerModels={routerModels}
643+
organizationAllowList={organizationAllowList}
644+
modelValidationError={modelValidationError}
645+
simplifySettings={fromWelcomeView}
646+
/>
647+
)}
648+
705649
{selectedProvider === "fireworks" && (
706650
<Fireworks
707651
apiConfiguration={apiConfiguration}

webview-ui/src/components/settings/ModelPicker.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type ModelIdKey = keyof Pick<
3535
| "litellmModelId"
3636
| "vercelAiGatewayModelId"
3737
| "opencodeGoModelId"
38+
| "zooGatewayModelId"
3839
| "apiModelId"
3940
| "ollamaModelId"
4041
| "lmStudioModelId"

webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { render, screen, fireEvent, within } from "@/utils/test-utils"
44
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
55

66
import { type ModelInfo, type ProviderSettings, openAiModelInfoSaneDefaults } from "@roo-code/types"
7-
import { openAiCodexDefaultModelId } from "@roo-code/types"
7+
import { openAiCodexDefaultModelId, zooGatewayDefaultModelId } from "@roo-code/types"
88

99
import * as ExtensionStateContext from "@src/context/ExtensionStateContext"
1010
const { ExtensionStateContextProvider } = ExtensionStateContext
@@ -300,6 +300,28 @@ describe("ApiOptions", () => {
300300
expect(mockSetApiConfigurationField).toHaveBeenCalledWith("apiModelId", openAiCodexDefaultModelId, false)
301301
})
302302

303+
it("initializes zooGatewayModelId to its default when switching provider to zoo-gateway", () => {
304+
// Regression: zoo-gateway was previously missing from PROVIDER_MODEL_CONFIG, so switching
305+
// providers never seeded zooGatewayModelId. Configs were left without a model id, which
306+
// blocked completion flows that require a dynamic-provider model id.
307+
const mockSetApiConfigurationField = vi.fn()
308+
309+
renderApiOptions({
310+
apiConfiguration: {
311+
apiProvider: "anthropic",
312+
// No prior zooGatewayModelId.
313+
},
314+
setApiConfigurationField: mockSetApiConfigurationField,
315+
})
316+
317+
const providerSelectContainer = screen.getByTestId("provider-select")
318+
const providerSelect = providerSelectContainer.querySelector("select") as HTMLSelectElement
319+
fireEvent.change(providerSelect, { target: { value: "zoo-gateway" } })
320+
321+
expect(mockSetApiConfigurationField).toHaveBeenCalledWith("apiProvider", "zoo-gateway")
322+
expect(mockSetApiConfigurationField).toHaveBeenCalledWith("zooGatewayModelId", zooGatewayDefaultModelId, false)
323+
})
324+
303325
it("shows temperature and rate limit controls by default", () => {
304326
renderApiOptions({
305327
apiConfiguration: {},

webview-ui/src/components/settings/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export const PROVIDERS = [
6464
{ value: "fireworks", label: "Fireworks AI", proxy: false },
6565
{ value: "vercel-ai-gateway", label: "Vercel AI Gateway", proxy: false },
6666
{ value: "opencode-go", label: "Opencode Go", proxy: false },
67+
{ value: "zoo-gateway", label: "Zoo Gateway", proxy: false },
6768
{ value: "minimax", label: "MiniMax", proxy: false },
6869
{ value: "mimo", label: "Xiaomi MiMo", proxy: false },
6970
{ value: "baseten", label: "Baseten", proxy: false },
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { useEffect, useMemo } from "react"
2+
import {
3+
type ProviderSettings,
4+
type OrganizationAllowList,
5+
type RouterModels,
6+
zooGatewayDefaultModelId,
7+
} from "@roo-code/types"
8+
9+
import { useExtensionState } from "@src/context/ExtensionStateContext"
10+
import { getZooCodeAuthUrl } from "@src/oauth/urls"
11+
import { useAppTranslation } from "@src/i18n/TranslationContext"
12+
import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
13+
14+
import { ModelPicker } from "../ModelPicker"
15+
import { ApiErrorMessage } from "../ApiErrorMessage"
16+
17+
type ZooGatewayProps = {
18+
apiConfiguration: ProviderSettings
19+
setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
20+
routerModels?: RouterModels
21+
organizationAllowList: OrganizationAllowList
22+
modelValidationError?: string
23+
simplifySettings?: boolean
24+
}
25+
26+
function isClaudeSonnetModelId(id: string) {
27+
return /claude.*sonnet/i.test(id)
28+
}
29+
30+
// Exported for unit tests. Picks the default Zoo Gateway model id, preferring
31+
// Claude Sonnet 4.5 → Sonnet 4 → first available Sonnet → first model overall.
32+
export function pickZooGatewayDefaultModelId(modelIds: string[]) {
33+
if (modelIds.length === 0) {
34+
return zooGatewayDefaultModelId
35+
}
36+
37+
const sonnets = modelIds.filter(isClaudeSonnetModelId)
38+
if (sonnets.length === 0) {
39+
return modelIds[0]
40+
}
41+
42+
return (
43+
sonnets.find((id) => id === "anthropic/claude-sonnet-4.5") ??
44+
sonnets.find((id) => id.includes("claude-sonnet-4.5")) ??
45+
sonnets.find((id) => /sonnet-4[.-]5/i.test(id)) ??
46+
sonnets.find((id) => /sonnet-4(?![.-]?\d)/i.test(id)) ??
47+
sonnets[0]
48+
)
49+
}
50+
51+
export const ZooGateway = ({
52+
apiConfiguration,
53+
setApiConfigurationField,
54+
routerModels,
55+
organizationAllowList,
56+
modelValidationError,
57+
simplifySettings,
58+
}: ZooGatewayProps) => {
59+
const { t } = useAppTranslation()
60+
const { zooCodeIsAuthenticated, zooCodeUserEmail, zooCodeUserName, zooCodeBaseUrl, uriScheme, deviceName } =
61+
useExtensionState()
62+
63+
const authUrl = getZooCodeAuthUrl(uriScheme, zooCodeBaseUrl, deviceName)
64+
const resolvedDashboardBase = zooCodeBaseUrl?.replace(/\/$/, "") || "https://www.zoocode.dev"
65+
66+
const zooModels = useMemo(() => routerModels?.["zoo-gateway"] ?? {}, [routerModels])
67+
const modelIds = useMemo(() => Object.keys(zooModels), [zooModels])
68+
const resolvedDefaultModelId = useMemo(() => pickZooGatewayDefaultModelId(modelIds), [modelIds])
69+
70+
useEffect(() => {
71+
if (modelIds.length === 0) {
72+
return
73+
}
74+
75+
const current = apiConfiguration.zooGatewayModelId
76+
if (!current || !modelIds.includes(current)) {
77+
setApiConfigurationField("zooGatewayModelId", resolvedDefaultModelId)
78+
}
79+
}, [apiConfiguration.zooGatewayModelId, modelIds, resolvedDefaultModelId, setApiConfigurationField])
80+
81+
return (
82+
<>
83+
<div className="flex flex-col gap-1 rounded-md border border-vscode-panel-border p-2">
84+
<div className="flex items-center justify-between">
85+
<label className="block text-sm font-medium">{t("settings:providers.zooGateway.account")}</label>
86+
{zooCodeIsAuthenticated && zooCodeUserEmail && (
87+
<span className="text-xs text-vscode-descriptionForeground">{zooCodeUserEmail}</span>
88+
)}
89+
</div>
90+
{!zooCodeIsAuthenticated ? (
91+
<div className="flex flex-col gap-1">
92+
<ApiErrorMessage errorMessage={t("settings:validation.zooGatewaySignIn")} />
93+
<p className="text-xs text-vscode-descriptionForeground">
94+
{t("settings:providers.zooGateway.signInDescription")}
95+
</p>
96+
<VSCodeButtonLink href={authUrl} appearance="primary">
97+
{t("settings:providers.zooGateway.signInButton")}
98+
</VSCodeButtonLink>
99+
</div>
100+
) : (
101+
<div className="flex items-center gap-1">
102+
<span className="codicon codicon-check text-vscode-charts-green" />
103+
<span className="text-xs text-vscode-descriptionForeground">
104+
{zooCodeUserName
105+
? t("settings:providers.zooGateway.authenticatedAs", { name: zooCodeUserName })
106+
: t("settings:providers.zooGateway.authenticated")}
107+
</span>
108+
</div>
109+
)}
110+
</div>
111+
<ModelPicker
112+
apiConfiguration={apiConfiguration}
113+
setApiConfigurationField={setApiConfigurationField}
114+
defaultModelId={resolvedDefaultModelId}
115+
models={zooModels}
116+
modelIdKey="zooGatewayModelId"
117+
serviceName="Zoo Gateway"
118+
serviceUrl={`${resolvedDashboardBase}/dashboard/models`}
119+
organizationAllowList={organizationAllowList}
120+
errorMessage={modelValidationError}
121+
simplifySettings={simplifySettings}
122+
/>
123+
</>
124+
)
125+
}

0 commit comments

Comments
 (0)