fix: add z.ai coding plan support (#1370)

This commit is contained in:
Ralph Khreish
2025-11-01 14:35:22 +01:00
committed by GitHub
parent 560a469f5c
commit 9c3b2737dd
15 changed files with 200 additions and 28 deletions

View File

@@ -0,0 +1,13 @@
---
"task-master-ai": patch
---
Add support for ZAI (GLM) Coding Plan subscription endpoint as a separate provider. Users can now select between two ZAI providers:
- **zai**: Standard ZAI endpoint (`https://api.z.ai/api/paas/v4/`)
- **zai-coding**: Coding Plan endpoint (`https://api.z.ai/api/coding/paas/v4/`)
Both providers use the same model IDs (glm-4.6, glm-4.5) but route to different API endpoints based on your subscription. When running `tm models --setup`, you'll see both providers listed separately:
- `zai / glm-4.6` - Standard endpoint
- `zai-coding / glm-4.6` - Coding Plan endpoint

View File

@@ -67,17 +67,21 @@ export function buildPromptChoices(
.flatMap(([provider, models]) => {
return models
.filter((m) => m.allowed_roles && m.allowed_roles.includes(role))
.map((m) => ({
name: `${provider} / ${m.id} ${
m.cost_per_1m_tokens
? chalk.gray(
`($${m.cost_per_1m_tokens.input.toFixed(2)} input | $${m.cost_per_1m_tokens.output.toFixed(2)} output)`
)
: ''
}`,
value: { id: m.id, provider },
short: `${provider}/${m.id}`
}));
.map((m) => {
// Use model name if available, otherwise fall back to model ID
const displayName = m.name || m.id;
return {
name: `${provider} / ${displayName} ${
m.cost_per_1m_tokens
? chalk.gray(
`($${m.cost_per_1m_tokens.input.toFixed(2)} input | $${m.cost_per_1m_tokens.output.toFixed(2)} output)`
)
: ''
}`,
value: { id: m.id, provider },
short: `${provider}/${displayName}`
};
});
})
.filter((choice) => choice !== null);

View File

@@ -36,6 +36,7 @@ export const CONTROL_VALUES = {
*/
export interface ModelInfo {
id: string;
name?: string;
provider: string;
cost_per_1m_tokens?: {
input: number;

View File

@@ -275,7 +275,7 @@
"tailwindcss": "4.1.11",
"typescript": "^5.9.2",
"@tm/core": "*",
"task-master-ai": "0.31.0-rc.0"
"task-master-ai": "*"
},
"overrides": {
"glob@<8": "^10.4.5",

View File

@@ -82,7 +82,7 @@ export function registerModelsTool(server) {
.string()
.optional()
.describe(
'Custom base URL for openai-compatible provider (e.g., https://api.example.com/v1)'
'Custom base URL for providers that support it (e.g., https://api.example.com/v1).'
)
}),
execute: withNormalizedProjectRoot(async (args, { log, session }) => {

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "task-master-ai",
"version": "0.30.2",
"version": "0.31.0-rc.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "task-master-ai",
"version": "0.30.2",
"version": "0.31.0-rc.0",
"license": "MIT WITH Commons-Clause",
"workspaces": [
"apps/*",

View File

@@ -9,6 +9,7 @@ export const VALIDATED_PROVIDERS = [
'openai',
'google',
'zai',
'zai-coding',
'perplexity',
'xai',
'groq',

View File

@@ -52,7 +52,8 @@ import {
PerplexityAIProvider,
VertexAIProvider,
XAIProvider,
ZAIProvider
ZAIProvider,
ZAICodingProvider
} from '../../src/ai-providers/index.js';
// Import the provider registry
@@ -64,6 +65,7 @@ const PROVIDERS = {
perplexity: new PerplexityAIProvider(),
google: new GoogleAIProvider(),
zai: new ZAIProvider(),
'zai-coding': new ZAICodingProvider(),
lmstudio: new LMStudioProvider(),
openai: new OpenAIProvider(),
xai: new XAIProvider(),

View File

@@ -836,6 +836,8 @@ function isApiKeySet(providerName, session = null, projectRoot = null) {
azure: 'AZURE_OPENAI_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
xai: 'XAI_API_KEY',
zai: 'ZAI_API_KEY',
'zai-coding': 'ZAI_API_KEY',
groq: 'GROQ_API_KEY',
vertex: 'GOOGLE_API_KEY', // Vertex uses the same key as Google
'claude-code': 'CLAUDE_CODE_API_KEY', // Not actually used, but included for consistency
@@ -922,6 +924,11 @@ function getMcpApiKeyStatus(providerName, projectRoot = null) {
apiKeyToCheck = mcpEnv.XAI_API_KEY;
placeholderValue = 'YOUR_XAI_API_KEY_HERE';
break;
case 'zai':
case 'zai-coding':
apiKeyToCheck = mcpEnv.ZAI_API_KEY;
placeholderValue = 'YOUR_ZAI_API_KEY_HERE';
break;
case 'groq':
apiKeyToCheck = mcpEnv.GROQ_API_KEY;
placeholderValue = 'YOUR_GROQ_API_KEY_HERE';

View File

@@ -922,19 +922,43 @@
"input": 0.2,
"output": 1.1
},
"allowed_roles": ["main", "fallback"],
"allowed_roles": ["main", "fallback", "research"],
"max_tokens": 131072,
"supported": true
}
],
"zai-coding": [
{
"id": "glm-4.6",
"swe_score": 0.68,
"cost_per_1m_tokens": {
"input": 0,
"output": 0
},
"allowed_roles": ["main", "fallback", "research"],
"max_tokens": 204800,
"supported": true
},
{
"id": "glm-4.5",
"swe_score": 0.65,
"cost_per_1m_tokens": {
"input": 0,
"output": 0
},
"allowed_roles": ["main", "fallback", "research"],
"max_tokens": 131072,
"supported": true
},
{
"id": "glm-4.5v",
"swe_score": 0.63,
"id": "glm-4.5-air",
"swe_score": 0.62,
"cost_per_1m_tokens": {
"input": 0.6,
"output": 1.8
"input": 0,
"output": 0
},
"allowed_roles": ["main", "fallback"],
"max_tokens": 64000,
"allowed_roles": ["main", "fallback", "research"],
"max_tokens": 131072,
"supported": true
}
],

View File

@@ -429,19 +429,30 @@ async function setModel(role, modelId, options = {}) {
let determinedProvider = null; // Initialize provider
let warningMessage = null;
// Find the model data in internal list initially to see if it exists at all
let modelData = availableModels.find((m) => m.id === modelId);
// Find the model data in internal list
// If we have a provider hint, search for exact provider+model match
// Otherwise, just search by model ID (will get first match)
let modelData;
if (providerHint) {
// Search for model with specific provider
modelData = availableModels.find(
(m) => m.id === modelId && m.provider === providerHint
);
} else {
// Search by ID only
modelData = availableModels.find((m) => m.id === modelId);
}
// --- Revised Logic: Prioritize providerHint --- //
if (providerHint) {
// Hint provided (--ollama or --openrouter flag used)
// Hint provided (from interactive setup or flag)
if (modelData && modelData.provider === providerHint) {
// Found internally AND provider matches the hint
// Found internally with matching provider
determinedProvider = providerHint;
report(
'info',
`Model ${modelId} found internally with matching provider hint ${determinedProvider}.`
`Model ${modelId} found internally with provider ${determinedProvider}.`
);
} else {
// Either not found internally, OR found but under a DIFFERENT provider than hinted.

View File

@@ -20,4 +20,5 @@ export { GrokCliProvider } from './grok-cli.js';
export { CodexCliProvider } from './codex-cli.js';
export { OpenAICompatibleProvider } from './openai-compatible.js';
export { ZAIProvider } from './zai.js';
export { ZAICodingProvider } from './zai-coding.js';
export { LMStudioProvider } from './lmstudio.js';

View File

@@ -0,0 +1,21 @@
/**
* zai-coding.js
* AI provider implementation for Z.ai (GLM) Coding Plan models.
* Uses the exclusive coding API endpoint with OpenAI-compatible API.
*/
import { OpenAICompatibleProvider } from './openai-compatible.js';
/**
* Z.ai Coding Plan provider supporting GLM models through the dedicated coding endpoint.
*/
export class ZAICodingProvider extends OpenAICompatibleProvider {
constructor() {
super({
name: 'Z.ai (Coding Plan)',
apiKeyEnvVar: 'ZAI_API_KEY',
requiresApiKey: true,
defaultBaseURL: 'https://api.z.ai/api/coding/paas/v4/'
});
}
}

View File

@@ -0,0 +1,80 @@
/**
* Tests for ZAICodingProvider
*/
import { ZAICodingProvider } from '../../../src/ai-providers/zai-coding.js';
describe('ZAICodingProvider', () => {
let provider;
beforeEach(() => {
provider = new ZAICodingProvider();
});
describe('constructor', () => {
it('should initialize with correct name', () => {
expect(provider.name).toBe('Z.ai (Coding Plan)');
});
it('should initialize with correct coding endpoint baseURL', () => {
expect(provider.defaultBaseURL).toBe(
'https://api.z.ai/api/coding/paas/v4/'
);
});
it('should inherit from OpenAICompatibleProvider', () => {
expect(provider).toHaveProperty('generateText');
expect(provider).toHaveProperty('streamText');
expect(provider).toHaveProperty('generateObject');
});
});
describe('getRequiredApiKeyName', () => {
it('should return ZAI_API_KEY environment variable name', () => {
expect(provider.getRequiredApiKeyName()).toBe('ZAI_API_KEY');
});
});
describe('isRequiredApiKey', () => {
it('should return true as API key is required', () => {
expect(provider.isRequiredApiKey()).toBe(true);
});
});
describe('getClient', () => {
it('should create client with API key', () => {
const params = { apiKey: 'test-key' };
const client = provider.getClient(params);
expect(client).toBeDefined();
});
it('should use coding endpoint by default', () => {
const params = {
apiKey: 'test-key'
};
const client = provider.getClient(params);
expect(client).toBeDefined();
// The provider should use the coding endpoint
});
it('should throw error when API key is missing', () => {
expect(() => {
provider.getClient({});
}).toThrow('Z.ai (Coding Plan) API key is required.');
});
});
describe('validateAuth', () => {
it('should validate API key is present', () => {
expect(() => {
provider.validateAuth({});
}).toThrow('Z.ai (Coding Plan) API key is required');
});
it('should pass with valid API key', () => {
expect(() => {
provider.validateAuth({ apiKey: 'test-key' });
}).not.toThrow();
});
});
});

View File

@@ -261,6 +261,13 @@ jest.unstable_mockModule('../../src/ai-providers/index.js', () => ({
getRequiredApiKeyName: jest.fn(() => 'ZAI_API_KEY'),
isRequiredApiKey: jest.fn(() => true)
})),
ZAICodingProvider: jest.fn(() => ({
generateText: jest.fn(),
streamText: jest.fn(),
generateObject: jest.fn(),
getRequiredApiKeyName: jest.fn(() => 'ZAI_API_KEY'),
isRequiredApiKey: jest.fn(() => true)
})),
LMStudioProvider: jest.fn(() => ({
generateText: jest.fn(),
streamText: jest.fn(),