Skip to content

Commit 2228bcf

Browse files
authored
Merge pull request #1518 from mnfst/z-ai-subscription
feat: add Z.ai GLM Coding Plan as a subscription provider
2 parents cf400d0 + 1b984e5 commit 2228bcf

17 files changed

+290
-18
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"manifest": patch
3+
---
4+
5+
Add Z.ai GLM Coding Plan as a subscription provider. Users can now connect their
6+
Z.ai Coding Plan subscription to access GLM-5.1 and other subscription-exclusive
7+
models through the routing system.

packages/backend/src/model-discovery/model-discovery.service.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1820,6 +1820,17 @@ describe('ModelDiscoveryService', () => {
18201820
expect(result.map((m) => m.id)).toContain('MiniMax-M2');
18211821
expect(result.map((m) => m.id)).not.toContain('MiniMax-M2.5');
18221822
});
1823+
1824+
it('should supplement Z.ai known models including glm-5.1', () => {
1825+
const raw: DiscoveredModel[] = [makeModel({ id: 'glm-4.7', provider: 'zai' })];
1826+
1827+
const result = supplementWithKnownModels(raw, 'zai');
1828+
1829+
expect(result.map((m) => m.id)).toContain('glm-5.1');
1830+
expect(result.map((m) => m.id)).toContain('glm-5-turbo');
1831+
expect(result.map((m) => m.id)).toContain('glm-4.5-air');
1832+
expect(result.filter((m) => m.id === 'glm-4.7')).toHaveLength(1);
1833+
});
18231834
});
18241835

18251836
/* ── applyCapabilities edge cases ── */

packages/backend/src/model-discovery/provider-model-fetcher.service.spec.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ describe('ProviderModelFetcherService', () => {
2727
'minimax-subscription',
2828
'qwen',
2929
'zai',
30+
'zai-subscription',
3031
'anthropic',
3132
'gemini',
3233
'openrouter',
@@ -380,9 +381,9 @@ describe('ProviderModelFetcherService', () => {
380381
});
381382
});
382383

383-
/* ── Z.AI blocklist ── */
384+
/* ── Z.ai subscription routing ── */
384385

385-
it('should filter glm-5.1 from zai models via blocklist', async () => {
386+
it('should return glm-5.1 from zai models (no longer blocklisted)', async () => {
386387
fetchSpy.mockResolvedValue({
387388
ok: true,
388389
json: async () => ({
@@ -391,7 +392,38 @@ describe('ProviderModelFetcherService', () => {
391392
});
392393

393394
const result = await service.fetch('zai', 'key');
394-
expect(result.map((m) => m.id)).toEqual(['glm-4.5', 'glm-5']);
395+
expect(result.map((m) => m.id)).toEqual(['glm-4.5', 'glm-5', 'glm-5.1']);
396+
});
397+
398+
it('should route zai+subscription to Coding Plan models endpoint', async () => {
399+
fetchSpy.mockResolvedValue({
400+
ok: true,
401+
json: async () => ({ data: [{ id: 'glm-5.1' }, { id: 'glm-4.7' }] }),
402+
});
403+
404+
const result = await service.fetch('zai', 'zai-sub-key', 'subscription');
405+
406+
expect(fetchSpy).toHaveBeenCalledWith(
407+
'https://open.bigmodel.cn/api/coding/paas/v4/models',
408+
expect.objectContaining({
409+
headers: expect.objectContaining({ Authorization: 'Bearer zai-sub-key' }),
410+
}),
411+
);
412+
expect(result.map((m) => m.id)).toEqual(['glm-5.1', 'glm-4.7']);
413+
});
414+
415+
it('should route zai+api_key to standard models endpoint', async () => {
416+
fetchSpy.mockResolvedValue({
417+
ok: true,
418+
json: async () => ({ data: [{ id: 'glm-4.7' }] }),
419+
});
420+
421+
await service.fetch('zai', 'zai-key');
422+
423+
expect(fetchSpy).toHaveBeenCalledWith(
424+
'https://open.bigmodel.cn/api/paas/v4/models',
425+
expect.any(Object),
426+
);
395427
});
396428

397429
/* ── OpenAI-compatible providers use same parser ── */

packages/backend/src/model-discovery/provider-model-fetcher.service.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,6 @@ export const PROVIDER_BLOCKLIST: Record<string, ReadonlySet<string>> = {
126126
mistral: new Set([
127127
'voxtral-mini-2602', // Invalid model returned by API; not a real chat endpoint
128128
]),
129-
zai: new Set([
130-
'glm-5.1', // Requires Coding Plan subscription + different endpoint; 403 on standard API
131-
]),
132129
};
133130

134131
/** Filter models that are not compatible with chat completions. */
@@ -383,6 +380,11 @@ export const PROVIDER_CONFIGS: Record<string, FetcherConfig> = {
383380
buildHeaders: bearerHeaders,
384381
parse: parseOpenAI,
385382
},
383+
'zai-subscription': {
384+
endpoint: 'https://open.bigmodel.cn/api/coding/paas/v4/models',
385+
buildHeaders: bearerHeaders,
386+
parse: parseOpenAI,
387+
},
386388
anthropic: {
387389
endpoint: 'https://api.anthropic.com/v1/models?limit=100',
388390
buildHeaders: (key: string, authType?: string) => {
@@ -449,6 +451,8 @@ export class ProviderModelFetcherService {
449451
configKey = 'openai-subscription';
450452
} else if (configKey === 'minimax' && authType === 'subscription') {
451453
configKey = 'minimax-subscription';
454+
} else if (configKey === 'zai' && authType === 'subscription') {
455+
configKey = 'zai-subscription';
452456
}
453457
const config = PROVIDER_CONFIGS[configKey];
454458
if (!config) {

packages/backend/src/routing/proxy/__tests__/provider-client.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,43 @@ describe('ProviderClient', () => {
486486
});
487487
});
488488

489+
describe('Z.ai subscription provider', () => {
490+
it('routes to Coding Plan endpoint with subscription authType', async () => {
491+
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
492+
493+
await client.forward({
494+
provider: 'zai',
495+
apiKey: 'zai-sub-key',
496+
model: 'glm-5.1',
497+
body,
498+
stream: false,
499+
authType: 'subscription',
500+
});
501+
502+
const url = mockFetch.mock.calls[0][0] as string;
503+
expect(url).toBe('https://open.bigmodel.cn/api/coding/paas/v4/chat/completions');
504+
505+
const headers = mockFetch.mock.calls[0][1].headers;
506+
expect(headers['Authorization']).toBe('Bearer zai-sub-key');
507+
expect(headers['Content-Type']).toBe('application/json');
508+
});
509+
510+
it('routes to standard Z.ai endpoint for api_key auth', async () => {
511+
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
512+
513+
await client.forward({
514+
provider: 'zai',
515+
apiKey: 'zai-key',
516+
model: 'glm-4.7',
517+
body,
518+
stream: false,
519+
});
520+
521+
const url = mockFetch.mock.calls[0][0] as string;
522+
expect(url).toBe('https://api.z.ai/api/paas/v4/chat/completions');
523+
});
524+
});
525+
489526
describe('convertChatGptResponse', () => {
490527
it('delegates to fromResponsesResponse', () => {
491528
const data = {

packages/backend/src/routing/proxy/__tests__/provider-endpoints.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,28 @@ describe('PROVIDER_ENDPOINTS', () => {
231231
'Content-Type': 'application/json',
232232
});
233233
});
234+
235+
it('zai-subscription uses Coding Plan base URL', () => {
236+
const ep = PROVIDER_ENDPOINTS['zai-subscription'];
237+
expect(ep.baseUrl).toBe('https://open.bigmodel.cn/api/coding/paas/v4');
238+
});
239+
240+
it('zai-subscription builds /chat/completions path', () => {
241+
const path = PROVIDER_ENDPOINTS['zai-subscription'].buildPath('glm-5.1');
242+
expect(path).toBe('/chat/completions');
243+
});
244+
245+
it('zai-subscription uses openai format', () => {
246+
expect(PROVIDER_ENDPOINTS['zai-subscription'].format).toBe('openai');
247+
});
248+
249+
it('zai-subscription uses Bearer auth headers', () => {
250+
const headers = PROVIDER_ENDPOINTS['zai-subscription'].buildHeaders('zai-api-key');
251+
expect(headers).toEqual({
252+
Authorization: 'Bearer zai-api-key',
253+
'Content-Type': 'application/json',
254+
});
255+
});
234256
});
235257

236258
describe('buildEndpointOverride', () => {

packages/backend/src/routing/proxy/__tests__/proxy-fallback.service.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,5 +715,22 @@ describe('ProxyFallbackService', () => {
715715

716716
expect(result.apiKey).toBe('blob');
717717
});
718+
719+
it('returns Z.ai subscription API key unchanged (no OAuth unwrap)', async () => {
720+
const result = await resolveApiKey(
721+
'zai',
722+
'zai-sub-key',
723+
'subscription',
724+
'agent-1',
725+
'user-1',
726+
openaiOauth,
727+
minimaxOauth,
728+
);
729+
730+
expect(result.apiKey).toBe('zai-sub-key');
731+
expect(result.resourceUrl).toBeUndefined();
732+
expect(openaiOauth.unwrapToken).not.toHaveBeenCalled();
733+
expect(minimaxOauth.unwrapToken).not.toHaveBeenCalled();
734+
});
718735
});
719736
});

packages/backend/src/routing/proxy/provider-client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ export class ProviderClient {
7474
resolved = 'openai-subscription';
7575
} else if (resolved === 'minimax' && authType === 'subscription') {
7676
resolved = 'minimax-subscription';
77+
} else if (resolved === 'zai' && authType === 'subscription') {
78+
resolved = 'zai-subscription';
7779
}
7880
endpointKey = resolved;
7981
endpoint = PROVIDER_ENDPOINTS[endpointKey];

packages/backend/src/routing/proxy/provider-endpoints.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const anthropicBearerHeaders = (apiKey: string): Record<string, string> => ({
4545
*/
4646
const CHATGPT_SUBSCRIPTION_BASE = 'https://chatgpt.com/backend-api';
4747
const MINIMAX_SUBSCRIPTION_BASE = 'https://api.minimax.io/anthropic';
48+
const ZAI_SUBSCRIPTION_BASE = 'https://open.bigmodel.cn/api/coding/paas/v4';
4849
const chatgptSubscriptionHeaders = (apiKey: string) => ({
4950
Authorization: `Bearer ${apiKey}`,
5051
'Content-Type': 'application/json',
@@ -119,6 +120,12 @@ export const PROVIDER_ENDPOINTS: Record<string, ProviderEndpoint> = {
119120
buildPath: () => '/api/paas/v4/chat/completions',
120121
format: 'openai',
121122
},
123+
'zai-subscription': {
124+
baseUrl: ZAI_SUBSCRIPTION_BASE,
125+
buildHeaders: openaiHeaders,
126+
buildPath: () => '/chat/completions',
127+
format: 'openai',
128+
},
122129
google: {
123130
baseUrl: 'https://generativelanguage.googleapis.com',
124131
buildHeaders: () => ({ 'Content-Type': 'application/json' }),

packages/frontend/src/components/ProviderIcon.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -163,17 +163,15 @@ export function providerIcon(id: string, size: number = 20): JSX.Element | null
163163
</svg>
164164
);
165165

166-
/* ── Z.ai (Zhipu) ─────────────────────────────── */
166+
/* ── Z.ai ─────────────────────────────────────── */
167167
case 'zai':
168168
return (
169-
<svg
170-
style={s}
171-
viewBox="0 0 24 24"
172-
fill="currentColor"
173-
xmlns="http://www.w3.org/2000/svg"
174-
aria-hidden="true"
175-
>
176-
<path d="M4.5 4.5h15v3.75H9.375L19.5 16.5v3H4.5v-3.75h10.125L4.5 7.5z" />
169+
<svg style={s} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
170+
<rect width="24" height="24" rx="5" fill="#2d2d2d" />
171+
<path
172+
d="M5 5 L19 5 L19 8.5 L9 15.5 L19 15.5 L19 19 L5 19 L5 15.5 L15 8.5 L5 8.5 Z"
173+
fill="#ffffff"
174+
/>
177175
</svg>
178176
);
179177

0 commit comments

Comments
 (0)