diff --git a/.changeset/zai-inference-provider.md b/.changeset/zai-inference-provider.md new file mode 100644 index 000000000..19504684c --- /dev/null +++ b/.changeset/zai-inference-provider.md @@ -0,0 +1,5 @@ +--- +'@roomote/web': minor +--- + +Add Z.AI and Z.AI Coding Plan as inference providers with International or China region on connect. diff --git a/.env.production.example b/.env.production.example index 991a1ea1b..b1354fd8f 100644 --- a/.env.production.example +++ b/.env.production.example @@ -74,6 +74,10 @@ OPENROUTER_API_KEY= # MOONSHOT_API_KEY= # KIMI_API_KEY= # MINIMAX_API_KEY= +# ZAI_API_KEY= +# ZAI_REGION=global +# ZAI_CODING_PLAN_API_KEY= +# ZAI_CODING_PLAN_REGION=global # OPENCODE_API_KEY= # Compute provider. Use docker for a single-host deployment, or diff --git a/README.md b/README.md index 0bd15a3d4..3ee11cfde 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ cleans up after itself. 2. **API keys (BYOK).** Paste a key from OpenRouter, Anthropic, OpenAI, xAI, Google Gemini, Amazon Bedrock, Vercel AI Gateway, Baseten, Together AI, Moonshot AI (Kimi), Kimi for Coding, MiniMax, - OpenCode Zen / Go, or GitHub Copilot. + Z.AI (including Coding Plan), OpenCode Zen / Go, or GitHub Copilot. **Sandbox compute:** Modal, E2B, Daytona, Blaxel, and Local Docker. @@ -231,8 +231,8 @@ it runs. Two options. Connect your ChatGPT Plus or Pro subscription directly (no API key needed), or paste an API key from OpenRouter, Anthropic, OpenAI, xAI, Google Gemini, Amazon Bedrock, Vercel AI Gateway, Baseten, -Together AI, Moonshot AI (Kimi), Kimi for Coding, MiniMax, OpenCode Zen / Go, -or GitHub Copilot. +Together AI, Moonshot AI (Kimi), Kimi for Coding, MiniMax, Z.AI (including +Coding Plan), OpenCode Zen / Go, or GitHub Copilot. **What sandboxes does it support?** Modal, E2B, Daytona, Blaxel, and Local Docker. diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 811f9b9f8..fe23938ac 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -294,6 +294,9 @@ common provider keys into worker containers: - `MOONSHOT_API_KEY` - `KIMI_API_KEY` (Kimi for Coding, `kimi-for-coding/...` models) - `MINIMAX_API_KEY` +- `ZAI_API_KEY` (with `ZAI_REGION`: `global` or `china`, defaults to `global`) +- `ZAI_CODING_PLAN_API_KEY` (Z.AI Coding Plan, `zai-coding-plan/...` models, + with `ZAI_CODING_PLAN_REGION`) - `OPENCODE_API_KEY` If your provider uses another env var name, list it in diff --git a/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts b/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts index e92912bbb..0f9dacd32 100644 --- a/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts +++ b/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts @@ -119,9 +119,15 @@ describe('inference gateway', () => { const nameList = typeof names === 'string' ? [names] : names; // Region lookups resolve separately from API keys. - return nameList.includes('AWS_REGION') - ? undefined - : 'provider-secret-key'; + if ( + nameList.includes('AWS_REGION') || + nameList.includes('ZAI_REGION') || + nameList.includes('ZAI_CODING_PLAN_REGION') + ) { + return undefined; + } + + return 'provider-secret-key'; }, ); }); @@ -281,6 +287,103 @@ describe('inference gateway', () => { ); }); + it('proxies Z.AI to the international v4 chat completions endpoint by default', async () => { + const fetchMock = stubUpstreamFetch(); + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/zai/chat/completions', + ); + + expect(response.status).toBe(200); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.z.ai/api/paas/v4/chat/completions'); + expect(new Headers(init.headers).get('authorization')).toBe( + 'Bearer provider-secret-key', + ); + }); + + it('proxies Z.AI to the China host when ZAI_REGION is china', async () => { + mockResolveModelProviderEnvValue.mockImplementation( + async (names: string | readonly string[]) => { + const nameList = typeof names === 'string' ? [names] : names; + if (nameList.includes('ZAI_REGION')) { + return 'china'; + } + if ( + nameList.includes('AWS_REGION') || + nameList.includes('ZAI_CODING_PLAN_REGION') + ) { + return undefined; + } + return 'provider-secret-key'; + }, + ); + + const fetchMock = stubUpstreamFetch(); + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/zai/chat/completions', + ); + + expect(response.status).toBe(200); + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://open.bigmodel.cn/api/paas/v4/chat/completions'); + }); + + it('proxies Z.AI Coding Plan to its international coding endpoint', async () => { + const fetchMock = stubUpstreamFetch(); + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/zai-coding-plan/chat/completions', + ); + + expect(response.status).toBe(200); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.z.ai/api/coding/paas/v4/chat/completions'); + expect(new Headers(init.headers).get('authorization')).toBe( + 'Bearer provider-secret-key', + ); + }); + + it('rejects OpenAI-style /v1 paths on Z.AI', async () => { + const fetchMock = stubUpstreamFetch(); + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/zai/v1/chat/completions', + ); + + expect(response.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects a Z.AI region with no configured upstream base', async () => { + mockResolveModelProviderEnvValue.mockImplementation( + async (names: string | readonly string[]) => { + const nameList = typeof names === 'string' ? [names] : names; + if (nameList.includes('ZAI_REGION')) { + return 'us-east-1'; + } + if ( + nameList.includes('AWS_REGION') || + nameList.includes('ZAI_CODING_PLAN_REGION') + ) { + return undefined; + } + return 'provider-secret-key'; + }, + ); + + const fetchMock = stubUpstreamFetch(); + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/zai/chat/completions', + ); + + expect(response.status).toBe(500); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('proxies GitHub Copilot without a /v1 base-path suffix', async () => { mockGetGitHubCopilotAccessToken.mockResolvedValue('github-oauth-token'); const fetchMock = stubUpstreamFetch(); diff --git a/apps/api/src/handlers/inference/registry.ts b/apps/api/src/handlers/inference/registry.ts index 47e29c074..fdf8a325f 100644 --- a/apps/api/src/handlers/inference/registry.ts +++ b/apps/api/src/handlers/inference/registry.ts @@ -212,6 +212,20 @@ async function resolveProviderUpstreamBaseUrl( (await resolveModelProviderEnvValue([provider.region.envVarName])) ?? provider.region.default; + // Providers with discrete regional hosts select a base outright; the + // `{region}` template and its cloud-region pattern do not apply to them. + if (provider.region.baseUrls) { + const baseUrl = provider.region.baseUrls[region]; + + if (!baseUrl) { + throw new Error( + `${provider.region.envVarName} must be one of ${Object.keys(provider.region.baseUrls).join(', ')} for ${provider.name}. Received "${region}".`, + ); + } + + return baseUrl; + } + if (!INFERENCE_GATEWAY_REGION_PATTERN.test(region)) { throw new Error( `${provider.region.envVarName} must be a valid region for ${provider.name}. Received "${region}".`, diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index b41ec04d4..358b04856 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -184,6 +184,10 @@ as per-task auth tokens or workspace paths. | `MOONSHOT_API_KEY` | Provider key | Moonshot AI / Kimi Open Platform API key. | | `KIMI_API_KEY` | Provider key | Kimi for Coding API key for `kimi-for-coding/...` models. | | `MINIMAX_API_KEY` | Provider key | MiniMax API key. | +| `ZAI_API_KEY` | Provider key | Z.AI platform API key for the region set by `ZAI_REGION`. | +| `ZAI_REGION` | Provider key | Z.AI region: `global` (International) or `china`. Defaults to `global` when unset. | +| `ZAI_CODING_PLAN_API_KEY` | Provider key | Z.AI Coding Plan API key for `zai-coding-plan/...` models. Not a general platform key. | +| `ZAI_CODING_PLAN_REGION` | Provider key | Z.AI Coding Plan region: `global` (International) or `china`. Defaults to `global` when unset. | | `OPENCODE_API_KEY` | Provider key | OpenCode Zen / Go API key. | | `GEMINI_API_KEY` | Provider key | Google Gemini API key. Can also be saved from **Settings > Models**. | | `GOOGLE_GENERATIVE_AI_API_KEY` | Provider key | Alternate Google/Gemini provider key forwarded when configured or inferred. | diff --git a/apps/docs/models.mdx b/apps/docs/models.mdx index bc7de68fa..31373bace 100644 --- a/apps/docs/models.mdx +++ b/apps/docs/models.mdx @@ -16,9 +16,10 @@ Configure models from **Settings > Models**. An inference provider is the service that hosts or routes model calls. Roomote supports providers such as OpenRouter, Vercel AI Gateway, Baseten, -Together AI, OpenAI, Anthropic, Moonshot AI, Kimi for Coding, MiniMax, OpenCode, -Amazon Bedrock, Google Gemini, xAI, GitHub Copilot, ChatGPT subscriptions, and -OpenAI-compatible endpoints such as LiteLLM, Ollama, and vLLM. +Together AI, OpenAI, Anthropic, Moonshot AI, Kimi for Coding, MiniMax, Z.AI, +Z.AI Coding Plan, OpenCode, Amazon Bedrock, Google Gemini, xAI, GitHub Copilot, +ChatGPT subscriptions, and OpenAI-compatible endpoints such as LiteLLM, Ollama, +and vLLM. You can connect more than one inference provider in the same deployment. That lets you mix and match models by provider instead of betting the whole @@ -117,8 +118,8 @@ R_EXPLORE_MODEL=openrouter/openai/gpt-5.6-luna Roomote automatically forwards common provider keys to task workers, including OpenRouter, Vercel AI Gateway, OpenAI, Anthropic, Google Gemini, -Moonshot, Kimi for Coding, MiniMax, OpenCode, Amazon Bedrock, xAI, and GitHub -Copilot keys. +Moonshot, Kimi for Coding, MiniMax, Z.AI, OpenCode, Amazon Bedrock, xAI, and +GitHub Copilot keys. Use `R_MODEL_ENV_KEYS` when a provider key uses a custom env var name: ```sh diff --git a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx index 94708e1e0..5b653c696 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx @@ -6,6 +6,8 @@ import { toast } from 'sonner'; import { CHATGPT_SUBSCRIPTION_PROVIDER_ID, OPENAI_COMPATIBLE_PROVIDER_ID, + getDefaultAdditionalEnvValues, + getSetupModelProvider, type SetupModelProviderId, type SetupModelStatus, } from '@roomote/types'; @@ -23,6 +25,7 @@ import { SelectValue, Spinner, } from '@/components/system'; +import { AdditionalEnvFieldInput } from '@/components/settings/AdditionalEnvFieldInput'; import { ChatGptConnectDialog } from '@/components/settings/ChatGptConnectDialog'; import { GitHubCopilotConnectDialog } from '@/components/settings/GitHubCopilotConnectDialog'; @@ -133,7 +136,16 @@ export function StepInferenceProvider({ : '', ); setConnectionName(''); - setAdditionalEnvValues({}); + // Seeded from the catalog rather than the fetched status so this effect + // stays keyed on `selectedProvider` alone; depending on the status query + // would reset in-progress input on every refetch. + setAdditionalEnvValues( + getDefaultAdditionalEnvValues( + selectedProvider + ? (getSetupModelProvider(selectedProvider).additionalEnvFields ?? []) + : [], + ), + ); setEditingSavedValue(false); setIsChatGptDialogOpen(false); setIsGitHubCopilotDialogOpen(false); @@ -452,19 +464,18 @@ export function StepInferenceProvider({ {field.label} {field.required ? '' : ' (optional)'} - + onValueChange={(value) => setAdditionalEnvValues((values) => ({ ...values, - [field.envVarName]: event.target.value, + [field.envVarName]: value, })) } - placeholder={field.placeholder} disabled={saveModelConfig.isPending} - aria-label={`${field.label} for ${selectedProviderStatus?.label ?? 'provider'}`} - data-1p-ignore + ariaLabel={`${field.label} for ${selectedProviderStatus?.label ?? 'provider'}`} + selectTriggerClassName="min-w-44" /> ))} diff --git a/apps/web/src/components/settings/AdditionalEnvFieldInput.tsx b/apps/web/src/components/settings/AdditionalEnvFieldInput.tsx new file mode 100644 index 000000000..cd17fef62 --- /dev/null +++ b/apps/web/src/components/settings/AdditionalEnvFieldInput.tsx @@ -0,0 +1,72 @@ +'use client'; + +import type { SetupModelProviderEnvField } from '@roomote/types'; + +import { + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/system'; + +/** + * One additional provider env field on a connect surface: a select when the + * field declares options, a text input otherwise. Shared by the settings + * dialog and the onboarding step so both render a field the same way. + */ +export function AdditionalEnvFieldInput({ + field, + value, + onValueChange, + disabled, + ariaLabel, + inputClassName, + selectTriggerClassName, +}: { + field: SetupModelProviderEnvField; + value: string; + onValueChange: (value: string) => void; + disabled: boolean; + ariaLabel: string; + inputClassName?: string; + selectTriggerClassName?: string; +}) { + if (field.options && field.options.length > 0) { + return ( + + ); + } + + return ( + onValueChange(event.target.value)} + placeholder={field.placeholder} + disabled={disabled} + aria-label={ariaLabel} + data-1p-ignore + /> + ); +} diff --git a/apps/web/src/components/settings/InferenceProviderSection.test.tsx b/apps/web/src/components/settings/InferenceProviderSection.test.tsx index 44b8dac85..223984df1 100644 --- a/apps/web/src/components/settings/InferenceProviderSection.test.tsx +++ b/apps/web/src/components/settings/InferenceProviderSection.test.tsx @@ -692,6 +692,61 @@ describe('InferenceProviderSection', () => { }); }); + it('submits the first option for an untouched selectable field', async () => { + const { providerSetup } = buildProviderSetup(); + providerSetup.providers = [ + { + id: 'zai' as SetupModelProviderId, + label: 'Z.AI', + envVarName: 'ZAI_API_KEY', + defaultRoomoteModel: 'zai/glm-5.2', + authKind: 'api-key', + suggestedTaskModels: [], + additionalEnvFields: [ + { + envVarName: 'ZAI_REGION', + label: 'Region', + secret: false, + required: true, + options: [ + { value: 'global', label: 'International' }, + { value: 'china', label: 'China' }, + ], + }, + ], + runtimeApiKeySatisfied: false, + savedApiKeySatisfied: false, + additionalEnvValues: {}, + }, + ]; + providerSetupData.current = { providerSetup }; + mutateAsyncMock.mockResolvedValue({}); + + renderInferenceProviderSection(); + + fireEvent.click(screen.getByRole('button', { name: /Add provider/ })); + + // A field with options renders as a select showing its first option, and + // saving must submit that value rather than the empty string the user + // never typed into. + expect( + screen.getByRole('combobox', { name: 'Region for Z.AI' }), + ).toHaveTextContent('International'); + + await act(async () => { + fireEvent.change(screen.getByLabelText('API key for Z.AI'), { + target: { value: 'zai-key' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + }); + + expect(mutateAsyncMock).toHaveBeenCalledWith({ + provider: 'zai', + apiKey: 'zai-key', + additionalEnvValues: { ZAI_REGION: 'global' }, + }); + }); + it('closes the endpoint dialog after connecting', async () => { const { providerSetup } = buildProviderSetup(); providerSetup.providers = [ diff --git a/apps/web/src/components/settings/InferenceProviderSection.tsx b/apps/web/src/components/settings/InferenceProviderSection.tsx index cee52a6d7..9385a7012 100644 --- a/apps/web/src/components/settings/InferenceProviderSection.tsx +++ b/apps/web/src/components/settings/InferenceProviderSection.tsx @@ -6,6 +6,7 @@ import { toast } from 'sonner'; import { CHATGPT_SUBSCRIPTION_PROVIDER_ID, OPENAI_COMPATIBLE_PROVIDER_ID, + getDefaultAdditionalEnvValues, getModelProviderLabel, } from '@roomote/types'; import type { @@ -44,6 +45,7 @@ import { Trash2, } from '@/components/system'; import { Section } from '@/components/settings/Section'; +import { AdditionalEnvFieldInput } from '@/components/settings/AdditionalEnvFieldInput'; import { ChatGptConnectDialog } from '@/components/settings/ChatGptConnectDialog'; import { GitHubCopilotConnectDialog } from '@/components/settings/GitHubCopilotConnectDialog'; import { ProviderCreditBalanceLine } from '@/components/settings/ProviderCreditBalanceLine'; @@ -82,11 +84,16 @@ function getInitialAdditionalEnvValues( (provider.additionalEnvFields ?? []).map((field) => field.envVarName), ); - return Object.fromEntries( + const values = Object.fromEntries( Object.entries(provider.additionalEnvValues ?? {}).filter(([name]) => declaredNames.has(name), ), ); + + return getDefaultAdditionalEnvValues( + provider.additionalEnvFields ?? [], + values, + ); } function getInitialPrimaryCredential( @@ -112,11 +119,13 @@ function getSubmitAdditionalEnvValues( additionalEnvFields.map((field) => field.envVarName), ); - return Object.fromEntries( + const values = Object.fromEntries( Object.entries(additionalEnvValues).filter(([name]) => declaredNames.has(name), ), ); + + return getDefaultAdditionalEnvValues(additionalEnvFields, values); } function ConnectedProviderRow({ @@ -485,20 +494,18 @@ function ProviderCredentialsDialog({ {field.label} {field.required ? '' : ' (optional)'} - + onValueChange={(value) => setAdditionalEnvValues((values) => ({ ...values, - [field.envVarName]: event.target.value, + [field.envVarName]: value, })) } - placeholder={field.placeholder} disabled={isSaving} - aria-label={`${field.label} for ${selectedProvider.label}`} - data-1p-ignore + ariaLabel={`${field.label} for ${selectedProvider.label}`} + inputClassName={field.secret ? 'font-mono' : undefined} /> ))} diff --git a/apps/web/src/trpc/commands/task-models/index.test.ts b/apps/web/src/trpc/commands/task-models/index.test.ts index 6c292b8be..87c580dea 100644 --- a/apps/web/src/trpc/commands/task-models/index.test.ts +++ b/apps/web/src/trpc/commands/task-models/index.test.ts @@ -85,6 +85,10 @@ const PROVIDER_ENV_VAR_NAMES = [ 'MOONSHOT_API_KEY', 'KIMI_API_KEY', 'MINIMAX_API_KEY', + 'ZAI_API_KEY', + 'ZAI_REGION', + 'ZAI_CODING_PLAN_API_KEY', + 'ZAI_CODING_PLAN_REGION', 'OPENCODE_API_KEY', 'AWS_BEARER_TOKEN_BEDROCK', 'AWS_REGION', @@ -1342,6 +1346,8 @@ describe('task model provider commands', () => { expect(mockGetPersistedEnvironmentVariableValues).toHaveBeenCalledWith([ 'AWS_REGION', + 'ZAI_REGION', + 'ZAI_CODING_PLAN_REGION', 'OPENAI_COMPATIBLE_BASE_URL', 'LITELLM_BASE_URL', 'OLLAMA_BASE_URL', diff --git a/deploy/compose/docker-compose.prod.yml b/deploy/compose/docker-compose.prod.yml index e6c90e351..3480ffd9b 100644 --- a/deploy/compose/docker-compose.prod.yml +++ b/deploy/compose/docker-compose.prod.yml @@ -43,6 +43,10 @@ x-roomote-inference-env: &roomote-inference-env MOONSHOT_API_KEY: ${MOONSHOT_API_KEY:-} KIMI_API_KEY: ${KIMI_API_KEY:-} MINIMAX_API_KEY: ${MINIMAX_API_KEY:-} + ZAI_API_KEY: ${ZAI_API_KEY:-} + ZAI_REGION: ${ZAI_REGION:-} + ZAI_CODING_PLAN_API_KEY: ${ZAI_CODING_PLAN_API_KEY:-} + ZAI_CODING_PLAN_REGION: ${ZAI_CODING_PLAN_REGION:-} OPENCODE_API_KEY: ${OPENCODE_API_KEY:-} BASETEN_API_KEY: ${BASETEN_API_KEY:-} TOGETHER_API_KEY: ${TOGETHER_API_KEY:-} diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 7d52ac339..bb4d76510 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -46,6 +46,10 @@ x-roomote-production-env: &roomote-production-env MOONSHOT_API_KEY: ${MOONSHOT_API_KEY:-} KIMI_API_KEY: ${KIMI_API_KEY:-} MINIMAX_API_KEY: ${MINIMAX_API_KEY:-} + ZAI_API_KEY: ${ZAI_API_KEY:-} + ZAI_REGION: ${ZAI_REGION:-} + ZAI_CODING_PLAN_API_KEY: ${ZAI_CODING_PLAN_API_KEY:-} + ZAI_CODING_PLAN_REGION: ${ZAI_CODING_PLAN_REGION:-} OPENCODE_API_KEY: ${OPENCODE_API_KEY:-} BASETEN_API_KEY: ${BASETEN_API_KEY:-} TOGETHER_API_KEY: ${TOGETHER_API_KEY:-} diff --git a/docker-compose.self-host.yml b/docker-compose.self-host.yml index 070c8cced..99050bb97 100644 --- a/docker-compose.self-host.yml +++ b/docker-compose.self-host.yml @@ -44,6 +44,10 @@ x-roomote-env: &roomote-env MOONSHOT_API_KEY: ${MOONSHOT_API_KEY:-} KIMI_API_KEY: ${KIMI_API_KEY:-} MINIMAX_API_KEY: ${MINIMAX_API_KEY:-} + ZAI_API_KEY: ${ZAI_API_KEY:-} + ZAI_REGION: ${ZAI_REGION:-} + ZAI_CODING_PLAN_API_KEY: ${ZAI_CODING_PLAN_API_KEY:-} + ZAI_CODING_PLAN_REGION: ${ZAI_CODING_PLAN_REGION:-} OPENCODE_API_KEY: ${OPENCODE_API_KEY:-} BASETEN_API_KEY: ${BASETEN_API_KEY:-} TOGETHER_API_KEY: ${TOGETHER_API_KEY:-} diff --git a/ecosystem.config.js b/ecosystem.config.js index 6e915bb1f..190ae7c9b 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -9,6 +9,10 @@ const DEFAULT_OPENCODE_PROVIDER_ENV_KEYS = [ 'MOONSHOT_API_KEY', 'KIMI_API_KEY', 'MINIMAX_API_KEY', + 'ZAI_API_KEY', + 'ZAI_REGION', + 'ZAI_CODING_PLAN_API_KEY', + 'ZAI_CODING_PLAN_REGION', 'OPENCODE_API_KEY', 'BASETEN_API_KEY', 'TOGETHER_API_KEY', diff --git a/packages/types/src/__tests__/inference-gateway.test.ts b/packages/types/src/__tests__/inference-gateway.test.ts index 609e48147..64cdb88b3 100644 --- a/packages/types/src/__tests__/inference-gateway.test.ts +++ b/packages/types/src/__tests__/inference-gateway.test.ts @@ -5,9 +5,11 @@ import { getInferenceGatewayProvider, getInferenceGatewayProviderByEnvVarName, INFERENCE_GATEWAY_PROVIDER_ENV_VAR_NAMES, + INFERENCE_GATEWAY_PROVIDERS, isInferenceGatewayCoveredEnvVar, parseInferenceGatewayKeys, } from '../inference-gateway'; +import { getSetupModelProvider } from '../model-provider-config'; describe('inference gateway URL builders', () => { it('appends the gateway path to a platform URL', () => { @@ -52,6 +54,36 @@ describe('inference gateway URL builders', () => { ); }); + it('registers Z.AI providers with v4 bases and empty OpenCode suffix', () => { + const zai = getInferenceGatewayProvider('zai'); + expect(zai).toMatchObject({ + envVarNames: ['ZAI_API_KEY'], + openCodeBaseUrlSuffix: '', + region: { + envVarName: 'ZAI_REGION', + default: 'global', + baseUrls: { + global: 'https://api.z.ai/api/paas/v4', + china: 'https://open.bigmodel.cn/api/paas/v4', + }, + }, + }); + expect(zai?.allowedPaths).toContain('/chat/completions'); + expect(zai?.allowedPaths).not.toContain('/v1/chat/completions'); + expect( + buildInferenceGatewayOpenCodeBaseUrl( + 'https://api.example.com/api/inference', + zai!, + ), + ).toBe('https://api.example.com/api/inference/zai'); + expect(getInferenceGatewayProviderByEnvVarName('ZAI_API_KEY')?.id).toBe( + 'zai', + ); + expect( + getInferenceGatewayProviderByEnvVarName('ZAI_CODING_PLAN_API_KEY')?.id, + ).toBe('zai-coding-plan'); + }); + it('exposes a chatgpt-oauth provider that collapses to the Codex backend', () => { const provider = getInferenceGatewayProvider(CHATGPT_GATEWAY_PROVIDER_ID); expect(provider?.authStrategy).toBe('chatgpt-oauth'); @@ -156,6 +188,29 @@ describe('inference gateway key lookups', () => { ).toBe('https://api.example.com/api/inference/github-copilot'); }); + it('offers exactly the regions its gateway providers hold base URLs for', () => { + const regionProviders = INFERENCE_GATEWAY_PROVIDERS.filter( + (provider) => provider.region?.baseUrls, + ); + + expect(regionProviders.map((provider) => provider.id)).toEqual([ + 'zai', + 'zai-coding-plan', + ]); + + for (const provider of regionProviders) { + const regions = Object.keys(provider.region!.baseUrls!); + + expect(regions).toContain(provider.region!.default); + + const field = ( + getSetupModelProvider(provider.id).additionalEnvFields ?? [] + ).find((entry) => entry.envVarName === provider.region!.envVarName); + + expect(field?.options?.map((option) => option.value)).toEqual(regions); + } + }); + it('parses a comma-separated served-keys value', () => { expect( parseInferenceGatewayKeys('ANTHROPIC_API_KEY, OPENROUTER_API_KEY'), diff --git a/packages/types/src/inference-gateway.ts b/packages/types/src/inference-gateway.ts index 9529a4cdd..dc159a501 100644 --- a/packages/types/src/inference-gateway.ts +++ b/packages/types/src/inference-gateway.ts @@ -123,7 +123,18 @@ export interface InferenceGatewayProvider { * Region resolution for `{region}`-templated upstreams: the deployment env * var to read and the fallback when it is unset. */ - region?: { envVarName: string; default: string }; + region?: { + envVarName: string; + default: string; + /** + * Discrete upstream bases keyed by region env value (e.g. `global` / + * `china`). When set, the resolved region selects a base URL outright + * instead of filling `upstreamBaseUrl`'s `{region}` placeholder, and + * `INFERENCE_GATEWAY_REGION_PATTERN` does not apply. Keeping these under + * `region` is what makes "bases without a region env var" unrepresentable. + */ + baseUrls?: Readonly>; + }; /** How the upstream expects its API key when the gateway forwards. */ authHeader?: InferenceGatewayAuthHeader; /** A configured upstream key is forwarded when present but is not required. */ @@ -167,6 +178,14 @@ const ANTHROPIC_COMPATIBLE_INFERENCE_PATHS: readonly string[] = [ '/v1/models', ]; +/** Paths relative to the models.dev v4 base (not OpenAI `/v1/...`). */ +const ZAI_INFERENCE_PATHS: readonly string[] = [ + '/chat/completions', + '/completions', + '/embeddings', + '/models', +]; + /** * Providers reachable through the inference gateway. HTTP-proxyable providers * whose API-key or OAuth credential can stay on the control plane. Google @@ -314,6 +333,38 @@ export const INFERENCE_GATEWAY_PROVIDERS: readonly InferenceGatewayProvider[] = ], openCodeBaseUrlSuffix: '/v1', }, + { + id: 'zai', + name: 'Z.AI', + envVarNames: ['ZAI_API_KEY'], + authHeader: { name: 'authorization', scheme: 'bearer' }, + allowedPaths: ZAI_INFERENCE_PATHS, + openCodeBaseUrlSuffix: '', + region: { + envVarName: 'ZAI_REGION', + default: 'global', + baseUrls: { + global: 'https://api.z.ai/api/paas/v4', + china: 'https://open.bigmodel.cn/api/paas/v4', + }, + }, + }, + { + id: 'zai-coding-plan', + name: 'Z.AI Coding Plan', + envVarNames: ['ZAI_CODING_PLAN_API_KEY'], + authHeader: { name: 'authorization', scheme: 'bearer' }, + allowedPaths: ZAI_INFERENCE_PATHS, + openCodeBaseUrlSuffix: '', + region: { + envVarName: 'ZAI_CODING_PLAN_REGION', + default: 'global', + baseUrls: { + global: 'https://api.z.ai/api/coding/paas/v4', + china: 'https://open.bigmodel.cn/api/coding/paas/v4', + }, + }, + }, { // GitHub Copilot's OpenCode SDK hits /chat/completions and /responses // under https://api.githubcopilot.com (no /v1 prefix on the base URL). diff --git a/packages/types/src/model-provider-config.test.ts b/packages/types/src/model-provider-config.test.ts index 31d3f4b8a..4846a0959 100644 --- a/packages/types/src/model-provider-config.test.ts +++ b/packages/types/src/model-provider-config.test.ts @@ -230,6 +230,8 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { 'amazon-bedrock', 'google', 'xai', + 'zai', + 'zai-coding-plan', 'github-copilot', 'openai-compatible', 'litellm', @@ -805,6 +807,18 @@ describe('getModelProviderEnvKeyCandidates', () => { ); expect(DEFAULT_MODEL_PROVIDER_ENV_KEYS).not.toContain('MISTRAL_API_KEY'); expect(DEFAULT_MODEL_PROVIDER_ENV_KEYS).toContain('GEMINI_API_KEY'); + expect(DEFAULT_MODEL_PROVIDER_ENV_KEYS).toContain('ZAI_API_KEY'); + expect(DEFAULT_MODEL_PROVIDER_ENV_KEYS).toContain('ZAI_REGION'); + expect(DEFAULT_MODEL_PROVIDER_ENV_KEYS).toContain( + 'ZAI_CODING_PLAN_API_KEY', + ); + expect(DEFAULT_MODEL_PROVIDER_ENV_KEYS).toContain('ZAI_CODING_PLAN_REGION'); + expect(DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES).toContain( + 'ZAI_API_KEY', + ); + expect(DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES).not.toContain( + 'ZAI_REGION', + ); expect(DEFAULT_MODEL_PROVIDER_ENV_KEYS).not.toContain('GITHUB_TOKEN'); // Ambient AWS access keys are intentionally NOT forwarded by default so a // controller's own infrastructure credentials never leak into sandboxes; @@ -1273,4 +1287,42 @@ describe('collectSetupModelProviderCredentialValues', () => { }), ).toThrow('Anthropic does not accept a ANTHROPIC_API_KEY value.'); }); + + it('accepts a listed option value for selectable fields', () => { + const zaiProvider = SETUP_MODEL_PROVIDER_CATALOG.find( + (provider) => provider.id === 'zai', + )!; + + expect( + collectSetupModelProviderCredentialValues({ + provider: zaiProvider, + apiKey: 'zai-key', + additionalEnvValues: { ZAI_REGION: 'china' }, + isEnvVarSatisfied: () => false, + action: 'save it', + }), + ).toEqual({ + values: [ + { name: 'ZAI_API_KEY', value: 'zai-key' }, + { name: 'ZAI_REGION', value: 'china' }, + ], + clearedEnvVarNames: [], + }); + }); + + it('rejects a value not in options for selectable fields', () => { + const zaiProvider = SETUP_MODEL_PROVIDER_CATALOG.find( + (provider) => provider.id === 'zai', + )!; + + expect(() => + collectSetupModelProviderCredentialValues({ + provider: zaiProvider, + apiKey: 'zai-key', + additionalEnvValues: { ZAI_REGION: 'us-east-1' }, + isEnvVarSatisfied: () => false, + action: 'save it', + }), + ).toThrow('Enter a valid Region for Z.AI to save it.'); + }); }); diff --git a/packages/types/src/model-provider-config.ts b/packages/types/src/model-provider-config.ts index 8d2e0c118..17fb8fc46 100644 --- a/packages/types/src/model-provider-config.ts +++ b/packages/types/src/model-provider-config.ts @@ -114,8 +114,20 @@ export type SetupModelProviderEnvField = { secret: boolean; required: boolean; placeholder?: string; + /** When set, connect UIs render a select; values must match an option. */ + options?: readonly { value: string; label: string }[]; }; +/** + * Region values must stay in step with the `region.baseUrls` keys on the + * matching inference gateway provider; the gateway has no base URL for a + * region this list offers. `inference-gateway.test.ts` asserts the pairing. + */ +export const ZAI_REGION_OPTIONS = [ + { value: 'global', label: 'International' }, + { value: 'china', label: 'China' }, +] as const; + export type SetupModelProviderDescriptor = { id: SetupModelProviderId; label: string; @@ -508,6 +520,61 @@ export const SETUP_MODEL_PROVIDER_CATALOG = [ 'grok-4-5': 'xai/grok-4.5', }), }, + { + id: 'zai', + label: 'Z.AI', + envVarName: 'ZAI_API_KEY', + defaultRoomoteModel: 'zai/glm-5.2', + authKind: 'api-key', + credentialHelp: { + text: 'Paste a platform API key for the selected region. International keys come from the Z.AI API console; China keys come from the Zhipu / BigModel console. Coding Plan membership keys belong on Z.AI Coding Plan, not here.', + href: 'https://z.ai/manage-apikey/apikey-list', + linkLabel: 'Open Z.AI API keys', + }, + additionalEnvFields: [ + { + envVarName: 'ZAI_REGION', + label: 'Region', + secret: false, + required: true, + options: ZAI_REGION_OPTIONS, + }, + ], + suggestedTaskModels: mapRecommendedTaskModels({ + 'glm-5-2': 'zai/glm-5.2', + }), + recommendedRoleModels: { + vision: 'zai/glm-5v-turbo', + }, + }, + { + id: 'zai-coding-plan', + label: 'Z.AI Coding Plan', + envVarName: 'ZAI_CODING_PLAN_API_KEY', + envVarLabel: 'Z.AI Coding Plan API key', + defaultRoomoteModel: 'zai-coding-plan/glm-5.2', + authKind: 'api-key', + credentialHelp: { + text: 'Paste a Coding Plan API key for the selected region. Do not use a general platform API key here.', + href: 'https://docs.z.ai/devpack/overview', + linkLabel: 'Open Z.AI Coding Plan docs', + }, + additionalEnvFields: [ + { + envVarName: 'ZAI_CODING_PLAN_REGION', + label: 'Region', + secret: false, + required: true, + options: ZAI_REGION_OPTIONS, + }, + ], + suggestedTaskModels: mapRecommendedTaskModels({ + 'glm-5-2': 'zai-coding-plan/glm-5.2', + }), + recommendedRoleModels: { + vision: 'zai-coding-plan/glm-5v-turbo', + }, + }, { // Provider id matches models.dev / OpenCode (`github-copilot`). // Connections use OpenCode's GitHub device-code OAuth flow. @@ -656,6 +723,30 @@ export function getSetupModelProviderAdditionalEnvFields(provider: { return provider.additionalEnvFields ?? []; } +/** + * Fills in the default for every selectable field that has no value yet. A + * select always shows one of its options, so connect UIs must submit that + * option rather than an empty string; existing values are left untouched. + */ +export function getDefaultAdditionalEnvValues( + fields: readonly SetupModelProviderEnvField[], + values: Record = {}, +): Record { + const seeded = { ...values }; + + for (const field of fields) { + if ( + field.options && + field.options.length > 0 && + !seeded[field.envVarName]?.trim() + ) { + seeded[field.envVarName] = field.options[0]!.value; + } + } + + return seeded; +} + function getSetupModelProviderRequiredEnvVarNames( provider: Pick< SetupModelProviderDescriptor, @@ -1334,6 +1425,14 @@ export function collectSetupModelProviderCredentialValues(options: { const value = submittedValue?.trim() ?? ''; if (value) { + if (field.options && field.options.length > 0) { + const allowed = new Set(field.options.map((option) => option.value)); + if (!allowed.has(value)) { + throw new Error( + `Enter a valid ${field.label} for ${provider.label} to ${options.action}.`, + ); + } + } values.push({ name: field.envVarName, value }); } else if (field.required) { if (!options.isEnvVarSatisfied(field.envVarName)) { diff --git a/packages/types/src/task-models.ts b/packages/types/src/task-models.ts index bf91c1375..456debf11 100644 --- a/packages/types/src/task-models.ts +++ b/packages/types/src/task-models.ts @@ -30,6 +30,8 @@ export const ENABLED_DIRECT_TASK_MODEL_PROVIDER_IDS = [ 'amazon-bedrock', 'google', 'xai', + 'zai', + 'zai-coding-plan', 'github-copilot', 'openai-compatible', 'litellm',