Skip to content
4 changes: 4 additions & 0 deletions src/api/providers/fetchers/__tests__/zoo-gateway.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import axios from "axios"
import { getZooGatewayModels, parseZooGatewayModel } from "../zoo-gateway"

vitest.mock("axios")
vitest.mock("../../../../services/zoo-code-auth", () => ({
getCachedZooCodeToken: vitest.fn(() => ""),
getZooCodeBaseUrl: vitest.fn(() => "https://example.test"),
}))
const mockedAxios = axios as any

describe("Zoo Gateway Fetchers", () => {
Expand Down
26 changes: 24 additions & 2 deletions webview-ui/src/components/settings/ApiOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
fireworksDefaultModelId,
vercelAiGatewayDefaultModelId,
opencodeGoDefaultModelId,
zooGatewayDefaultModelId,
minimaxDefaultModelId,
mimoDefaultModelId,
unboundDefaultModelId,
Expand Down Expand Up @@ -95,6 +96,7 @@ import {
Fireworks,
VercelAiGateway,
OpenCodeGo,
ZooGateway,
MiniMax,
Mimo,
} from "./providers"
Expand Down Expand Up @@ -135,7 +137,7 @@ const ApiOptions = ({
setErrorMessage,
}: ApiOptionsProps) => {
const { t } = useAppTranslation()
const { organizationAllowList, openAiCodexIsAuthenticated } = useExtensionState()
const { organizationAllowList, openAiCodexIsAuthenticated, zooCodeIsAuthenticated } = useExtensionState()

const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => {
const headers = apiConfiguration?.openAiHeaders || {}
Expand Down Expand Up @@ -272,9 +274,17 @@ const ApiOptions = ({
apiConfiguration,
routerModels,
organizationAllowList,
zooCodeIsAuthenticated,
)
setErrorMessage(apiValidationResult)
}, [apiConfiguration, routerModels, organizationAllowList, setErrorMessage, isRetiredSelectedProvider])
}, [
apiConfiguration,
routerModels,
organizationAllowList,
setErrorMessage,
isRetiredSelectedProvider,
zooCodeIsAuthenticated,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As @edelauna mentioned below, we should be wary of pumping zooCodeIsAuthenticated in the useEffect here as well. , I don't think we should be pumpingzooCodeIsAuthenticated here in the useEffect through here. if you remove it, then add

+		if (apiConfiguration.apiProvider === "zoo-gateway") {
+			setErrorMessage(undefined)
+			return
+		}

You will get a generic error message if something goes wrong.

If you want to add specific error messaging you can add it on the UI side in webview-ui/src/components/settings/providers/ZooGateway.tsx with ApiErrorMessage component

+import { ApiErrorMessage } from "../ApiErrorMessage"
@@
				{!zooCodeIsAuthenticated ? (
					<div className="flex flex-col gap-1">
+						<ApiErrorMessage errorMessage={t("settings:validation.zooGatewaySignIn")} />
						<p className="text-xs text-vscode-descriptionForeground">
							{t("settings:providers.zooGateway.signInDescription")}
						</p>
						<VSCodeButtonLink href={authUrl} appearance="primary">
							{t("settings:providers.zooGateway.signInButton")}
						</VSCodeButtonLink>
					</div>
				) : (

])

const onProviderChange = useCallback(
(value: ProviderName) => {
Expand Down Expand Up @@ -366,6 +376,7 @@ const ApiOptions = ({
poe: { field: "apiModelId", default: poeDefaultModelId },
"vercel-ai-gateway": { field: "vercelAiGatewayModelId", default: vercelAiGatewayDefaultModelId },
"opencode-go": { field: "opencodeGoModelId", default: opencodeGoDefaultModelId },
"zoo-gateway": { field: "zooGatewayModelId", default: zooGatewayDefaultModelId },
openai: { field: "openAiModelId" },
ollama: { field: "ollamaModelId" },
lmstudio: { field: "lmStudioModelId" },
Expand Down Expand Up @@ -702,6 +713,17 @@ const ApiOptions = ({
/>
)}

{selectedProvider === "zoo-gateway" && (
<ZooGateway
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
routerModels={routerModels}
organizationAllowList={organizationAllowList}
modelValidationError={modelValidationError}
simplifySettings={fromWelcomeView}
/>
)}

{selectedProvider === "fireworks" && (
<Fireworks
apiConfiguration={apiConfiguration}
Expand Down
1 change: 1 addition & 0 deletions webview-ui/src/components/settings/ModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type ModelIdKey = keyof Pick<
| "litellmModelId"
| "vercelAiGatewayModelId"
| "opencodeGoModelId"
| "zooGatewayModelId"
| "apiModelId"
| "ollamaModelId"
| "lmStudioModelId"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { render, screen, fireEvent, within } from "@/utils/test-utils"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"

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

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

it("initializes zooGatewayModelId to its default when switching provider to zoo-gateway", () => {
// Regression: zoo-gateway was previously missing from PROVIDER_MODEL_CONFIG, so switching
// providers never seeded zooGatewayModelId. Configs were left without a model id, which
// blocked completion flows that require a dynamic-provider model id.
const mockSetApiConfigurationField = vi.fn()

renderApiOptions({
apiConfiguration: {
apiProvider: "anthropic",
// No prior zooGatewayModelId.
},
setApiConfigurationField: mockSetApiConfigurationField,
})

const providerSelectContainer = screen.getByTestId("provider-select")
const providerSelect = providerSelectContainer.querySelector("select") as HTMLSelectElement
fireEvent.change(providerSelect, { target: { value: "zoo-gateway" } })

expect(mockSetApiConfigurationField).toHaveBeenCalledWith("apiProvider", "zoo-gateway")
expect(mockSetApiConfigurationField).toHaveBeenCalledWith("zooGatewayModelId", zooGatewayDefaultModelId, false)
})

it("shows temperature and rate limit controls by default", () => {
renderApiOptions({
apiConfiguration: {},
Expand Down
1 change: 1 addition & 0 deletions webview-ui/src/components/settings/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const PROVIDERS = [
{ value: "fireworks", label: "Fireworks AI", proxy: false },
{ value: "vercel-ai-gateway", label: "Vercel AI Gateway", proxy: false },
{ value: "opencode-go", label: "Opencode Go", proxy: false },
{ value: "zoo-gateway", label: "Zoo Gateway", proxy: false },
{ value: "minimax", label: "MiniMax", proxy: false },
{ value: "mimo", label: "Xiaomi MiMo", proxy: false },
{ value: "baseten", label: "Baseten", proxy: false },
Expand Down
125 changes: 125 additions & 0 deletions webview-ui/src/components/settings/providers/ZooGateway.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { useEffect, useMemo } from "react"
import {
type ProviderSettings,
type OrganizationAllowList,
type RouterModels,
zooGatewayDefaultModelId,
} from "@roo-code/types"

import { useExtensionState } from "@src/context/ExtensionStateContext"
import { getZooCodeAuthUrl } from "@src/oauth/urls"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"

import { ModelPicker } from "../ModelPicker"

type ZooGatewayProps = {
apiConfiguration: ProviderSettings
setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
routerModels?: RouterModels
organizationAllowList: OrganizationAllowList
modelValidationError?: string
simplifySettings?: boolean
}

function isSonnet45ModelId(id: string) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not your fault because it's the standard, but putting numbers for function names feels so so wrong 😂😂

return /sonnet-4[.-]5|sonnet-4\.5/i.test(id)
}

// Exported for unit tests.
export function pickZooGatewayDefaultModelId(modelIds: string[]) {
if (modelIds.length === 0) {
return zooGatewayDefaultModelId
}

const sonnet45 = modelIds.filter(isSonnet45ModelId)
if (sonnet45.length > 0) {
return (
sonnet45.find((id) => id === "anthropic/claude-sonnet-4.5") ??
sonnet45.find((id) => id.includes("claude-sonnet-4.5")) ??
sonnet45[0]
)
}

const sonnet4 = modelIds.filter((id) => /claude/i.test(id) && /sonnet/i.test(id) && /sonnet-4/i.test(id))
if (sonnet4.length > 0) {
return sonnet4[0]
}

return modelIds[0]
}

export const ZooGateway = ({
apiConfiguration,
setApiConfigurationField,
routerModels,
organizationAllowList,
modelValidationError,
simplifySettings,
}: ZooGatewayProps) => {
const { t } = useAppTranslation()
const { zooCodeIsAuthenticated, zooCodeUserEmail, zooCodeUserName, zooCodeBaseUrl, uriScheme, deviceName } =
useExtensionState()

const authUrl = getZooCodeAuthUrl(uriScheme, zooCodeBaseUrl, deviceName)
const resolvedDashboardBase = zooCodeBaseUrl?.replace(/\/$/, "") || "https://www.zoocode.dev"

const zooModels = useMemo(() => routerModels?.["zoo-gateway"] ?? {}, [routerModels])
const modelIds = useMemo(() => Object.keys(zooModels), [zooModels])
const resolvedDefaultModelId = useMemo(() => pickZooGatewayDefaultModelId(modelIds), [modelIds])

useEffect(() => {
if (modelIds.length === 0) {
return
}

const current = apiConfiguration.zooGatewayModelId
if (!current || !modelIds.includes(current)) {
setApiConfigurationField("zooGatewayModelId", resolvedDefaultModelId)
}
}, [apiConfiguration.zooGatewayModelId, modelIds, resolvedDefaultModelId, setApiConfigurationField])

return (
<>
<div className="flex flex-col gap-1 rounded-md border border-vscode-panel-border p-2">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium">{t("settings:providers.zooGateway.account")}</label>
{zooCodeIsAuthenticated && zooCodeUserEmail && (
<span className="text-xs text-vscode-descriptionForeground">{zooCodeUserEmail}</span>
)}
</div>
{!zooCodeIsAuthenticated ? (
<div className="flex flex-col gap-1">
<p className="text-xs text-vscode-descriptionForeground">
{t("settings:providers.zooGateway.signInDescription")}
</p>
<VSCodeButtonLink href={authUrl} appearance="primary">
{t("settings:providers.zooGateway.signInButton")}
</VSCodeButtonLink>
</div>
) : (
<div className="flex items-center gap-1">
<span className="codicon codicon-check text-vscode-charts-green" />
<span className="text-xs text-vscode-descriptionForeground">
{zooCodeUserName
? t("settings:providers.zooGateway.authenticatedAs", { name: zooCodeUserName })
: t("settings:providers.zooGateway.authenticated")}
</span>
</div>
)}
</div>
<ModelPicker
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
defaultModelId={resolvedDefaultModelId}
models={zooModels}
modelIdKey="zooGatewayModelId"
serviceName="Zoo Gateway"
serviceUrl={`${resolvedDashboardBase}/dashboard/models`}
organizationAllowList={organizationAllowList}
errorMessage={modelValidationError}
simplifySettings={simplifySettings}
/>
</>
)
}
Loading
Loading