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/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts b/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts index e92912bbb..93af849af 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,76 @@ 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('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..339636474 100644 --- a/apps/api/src/handlers/inference/registry.ts +++ b/apps/api/src/handlers/inference/registry.ts @@ -204,6 +204,21 @@ async function resolveProviderUpstreamBaseUrl( ); } + if (provider.regionBaseUrls && provider.region) { + const region = + (await resolveModelProviderEnvValue([provider.region.envVarName])) ?? + provider.region.default; + + const baseUrl = provider.regionBaseUrls[region]; + if (!baseUrl) { + throw new Error( + `${provider.region.envVarName} must be one of ${Object.keys(provider.regionBaseUrls).join(', ')} for ${provider.name}. Received "${region}".`, + ); + } + + return baseUrl; + } + if (!provider.region) { return provider.upstreamBaseUrl!; } diff --git a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx index 94708e1e0..5fc383572 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx @@ -6,6 +6,7 @@ import { toast } from 'sonner'; import { CHATGPT_SUBSCRIPTION_PROVIDER_ID, OPENAI_COMPATIBLE_PROVIDER_ID, + getSetupModelProvider, type SetupModelProviderId, type SetupModelStatus, } from '@roomote/types'; @@ -133,7 +134,16 @@ export function StepInferenceProvider({ : '', ); setConnectionName(''); - setAdditionalEnvValues({}); + const defaults: Record = {}; + if (selectedProvider) { + for (const field of getSetupModelProvider(selectedProvider) + .additionalEnvFields ?? []) { + if (field.options && field.options.length > 0) { + defaults[field.envVarName] = field.options[0]!.value; + } + } + } + setAdditionalEnvValues(defaults); setEditingSavedValue(false); setIsChatGptDialogOpen(false); setIsGitHubCopilotDialogOpen(false); @@ -452,20 +462,51 @@ export function StepInferenceProvider({ {field.label} {field.required ? '' : ' (optional)'} - - setAdditionalEnvValues((values) => ({ - ...values, - [field.envVarName]: event.target.value, - })) - } - placeholder={field.placeholder} - disabled={saveModelConfig.isPending} - aria-label={`${field.label} for ${selectedProviderStatus?.label ?? 'provider'}`} - data-1p-ignore - /> + {field.options && field.options.length > 0 ? ( + + ) : ( + + setAdditionalEnvValues((values) => ({ + ...values, + [field.envVarName]: event.target.value, + })) + } + placeholder={field.placeholder} + disabled={saveModelConfig.isPending} + aria-label={`${field.label} for ${selectedProviderStatus?.label ?? 'provider'}`} + data-1p-ignore + /> + )} ))} diff --git a/apps/web/src/components/settings/InferenceProviderSection.tsx b/apps/web/src/components/settings/InferenceProviderSection.tsx index cee52a6d7..48ffc9edb 100644 --- a/apps/web/src/components/settings/InferenceProviderSection.tsx +++ b/apps/web/src/components/settings/InferenceProviderSection.tsx @@ -82,11 +82,23 @@ function getInitialAdditionalEnvValues( (provider.additionalEnvFields ?? []).map((field) => field.envVarName), ); - return Object.fromEntries( + const values = Object.fromEntries( Object.entries(provider.additionalEnvValues ?? {}).filter(([name]) => declaredNames.has(name), ), ); + + for (const field of provider.additionalEnvFields ?? []) { + if ( + field.options && + field.options.length > 0 && + !values[field.envVarName]?.trim() + ) { + values[field.envVarName] = field.options[0]!.value; + } + } + + return values; } function getInitialPrimaryCredential( @@ -112,11 +124,23 @@ function getSubmitAdditionalEnvValues( additionalEnvFields.map((field) => field.envVarName), ); - return Object.fromEntries( + const values = Object.fromEntries( Object.entries(additionalEnvValues).filter(([name]) => declaredNames.has(name), ), ); + + for (const field of additionalEnvFields) { + if ( + field.options && + field.options.length > 0 && + !values[field.envVarName]?.trim() + ) { + values[field.envVarName] = field.options[0]!.value; + } + } + + return values; } function ConnectedProviderRow({ @@ -485,21 +509,56 @@ function ProviderCredentialsDialog({ {field.label} {field.required ? '' : ' (optional)'} - - setAdditionalEnvValues((values) => ({ - ...values, - [field.envVarName]: event.target.value, - })) - } - placeholder={field.placeholder} - disabled={isSaving} - aria-label={`${field.label} for ${selectedProvider.label}`} - data-1p-ignore - /> + {field.options && field.options.length > 0 ? ( + + ) : ( + + setAdditionalEnvValues((values) => ({ + ...values, + [field.envVarName]: event.target.value, + })) + } + placeholder={field.placeholder} + disabled={isSaving} + aria-label={`${field.label} for ${selectedProvider.label}`} + data-1p-ignore + /> + )} ))} 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..f3ec10baa 100644 --- a/packages/types/src/__tests__/inference-gateway.test.ts +++ b/packages/types/src/__tests__/inference-gateway.test.ts @@ -52,6 +52,32 @@ 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: '', + regionBaseUrls: { + 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'); diff --git a/packages/types/src/inference-gateway.ts b/packages/types/src/inference-gateway.ts index 9529a4cdd..81ecf9592 100644 --- a/packages/types/src/inference-gateway.ts +++ b/packages/types/src/inference-gateway.ts @@ -124,6 +124,11 @@ export interface InferenceGatewayProvider { * var to read and the fallback when it is unset. */ region?: { envVarName: string; default: string }; + /** + * Discrete upstream bases keyed by region env value (e.g. `global` / `china`). + * Not AWS-style regions; do not use `INFERENCE_GATEWAY_REGION_PATTERN`. + */ + regionBaseUrls?: 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 +172,14 @@ const ANTHROPIC_COMPATIBLE_INFERENCE_PATHS: readonly string[] = [ '/v1/models', ]; +/** Paths relative to the models.dev v4 base (not OpenAI `/v1/...`). */ +export 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 +327,32 @@ 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' }, + regionBaseUrls: { + 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' }, + regionBaseUrls: { + 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..66608761d 100644 --- a/packages/types/src/model-provider-config.ts +++ b/packages/types/src/model-provider-config.ts @@ -114,8 +114,17 @@ 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 }[]; }; +export const ZAI_REGION_OPTIONS = [ + { value: 'global', label: 'International' }, + { value: 'china', label: 'China' }, +] as const; + +export const DEFAULT_ZAI_REGION = 'global' as const; + export type SetupModelProviderDescriptor = { id: SetupModelProviderId; label: string; @@ -508,6 +517,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. @@ -1334,6 +1398,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',