From 9f9f32a55fe725d1a7b7ec1542a15afb9033d62b Mon Sep 17 00:00:00 2001 From: devkeruse Date: Fri, 16 Jan 2026 15:36:10 +0500 Subject: [PATCH 1/2] fix(opencode-provider): correct z.ai coding plan model mapping The model mapping for 'z.ai coding plan' was incorrectly pointing to 'z-ai' instead of 'zai-coding-plan', which would cause model resolution failures when users selected the z.ai coding plan provider. This fix ensures the correct model identifier is used for z.ai coding plan, aligning with the expected model naming convention. Co-Authored-By: Claude Sonnet 4.5 --- apps/server/src/providers/opencode-provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/providers/opencode-provider.ts b/apps/server/src/providers/opencode-provider.ts index 6babb978f..9de563787 100644 --- a/apps/server/src/providers/opencode-provider.ts +++ b/apps/server/src/providers/opencode-provider.ts @@ -1035,7 +1035,7 @@ export class OpencodeProvider extends CliProvider { 'lm studio': 'lmstudio', lmstudio: 'lmstudio', opencode: 'opencode', - 'z.ai coding plan': 'z-ai', + 'z.ai coding plan': 'zai-coding-plan', 'z.ai': 'z-ai', }; From b715769902b8b8b5626789b52674564bc576417d Mon Sep 17 00:00:00 2001 From: devkeruse Date: Sun, 18 Jan 2026 02:27:16 +0500 Subject: [PATCH 2/2] test: Add unit tests for parseProvidersOutput function Add comprehensive unit tests for the parseProvidersOutput private method in OpencodeProvider. This addresses PR feedback requesting test coverage for the z.ai coding plan mapping fix. Test coverage (22 tests): - Critical fix validation: z.ai coding plan vs z.ai distinction - Provider name mapping: all 12 providers with case-insensitive handling - Duplicate aliases: copilot, bedrock, lmstudio variants - Authentication methods: oauth, api_key detection - ANSI escape sequences: color code removal - Edge cases: malformed input, whitespace, newlines - Real-world CLI output: box characters, decorations All tests passing. Ensures regression protection for provider parsing. --- .../unit/providers/opencode-provider.test.ts | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) diff --git a/apps/server/tests/unit/providers/opencode-provider.test.ts b/apps/server/tests/unit/providers/opencode-provider.test.ts index 57e2fc384..641838efc 100644 --- a/apps/server/tests/unit/providers/opencode-provider.test.ts +++ b/apps/server/tests/unit/providers/opencode-provider.test.ts @@ -1311,4 +1311,317 @@ describe('opencode-provider.ts', () => { expect(args[modelIndex + 1]).toBe('provider/model-v1.2.3-beta'); }); }); + + // ========================================================================== + // parseProvidersOutput Tests + // ========================================================================== + + describe('parseProvidersOutput', () => { + // Helper function to access private method + function parseProviders(output: string) { + return ( + provider as unknown as { + parseProvidersOutput: (output: string) => Array<{ + id: string; + name: string; + authenticated: boolean; + authMethod?: 'oauth' | 'api_key'; + }>; + } + ).parseProvidersOutput(output); + } + + // ======================================================================= + // Critical Fix Validation + // ======================================================================= + + describe('Critical Fix Validation', () => { + it('should map "z.ai coding plan" to "zai-coding-plan" (NOT "z-ai")', () => { + const output = '● z.ai coding plan oauth'; + const result = parseProviders(output); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('zai-coding-plan'); + expect(result[0].name).toBe('z.ai coding plan'); + expect(result[0].authMethod).toBe('oauth'); + }); + + it('should map "z.ai" to "z-ai" (different from coding plan)', () => { + const output = '● z.ai api'; + const result = parseProviders(output); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('z-ai'); + expect(result[0].name).toBe('z.ai'); + expect(result[0].authMethod).toBe('api_key'); + }); + + it('should distinguish between "z.ai coding plan" and "z.ai"', () => { + const output = '● z.ai coding plan oauth\n● z.ai api'; + const result = parseProviders(output); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('zai-coding-plan'); + expect(result[0].name).toBe('z.ai coding plan'); + expect(result[1].id).toBe('z-ai'); + expect(result[1].name).toBe('z.ai'); + }); + }); + + // ======================================================================= + // Provider Name Mapping + // ======================================================================= + + describe('Provider Name Mapping', () => { + it('should map all 12 providers correctly', () => { + const output = `● anthropic oauth +● github copilot oauth +● google api +● openai api +● openrouter api +● azure api +● amazon bedrock oauth +● ollama api +● lm studio api +● opencode oauth +● z.ai coding plan oauth +● z.ai api`; + + const result = parseProviders(output); + + expect(result).toHaveLength(12); + expect(result.map((p) => p.id)).toEqual([ + 'anthropic', + 'github-copilot', + 'google', + 'openai', + 'openrouter', + 'azure', + 'amazon-bedrock', + 'ollama', + 'lmstudio', + 'opencode', + 'zai-coding-plan', + 'z-ai', + ]); + }); + + it('should handle case-insensitive provider names and preserve original casing', () => { + const output = '● Anthropic api\n● OPENAI oauth\n● GitHub Copilot oauth'; + const result = parseProviders(output); + + expect(result).toHaveLength(3); + expect(result[0].id).toBe('anthropic'); + expect(result[0].name).toBe('Anthropic'); // Preserves casing + expect(result[1].id).toBe('openai'); + expect(result[1].name).toBe('OPENAI'); // Preserves casing + expect(result[2].id).toBe('github-copilot'); + expect(result[2].name).toBe('GitHub Copilot'); // Preserves casing + }); + + it('should handle multi-word provider names with spaces', () => { + const output = '● Amazon Bedrock oauth\n● LM Studio api\n● GitHub Copilot oauth'; + const result = parseProviders(output); + + expect(result[0].id).toBe('amazon-bedrock'); + expect(result[0].name).toBe('Amazon Bedrock'); + expect(result[1].id).toBe('lmstudio'); + expect(result[1].name).toBe('LM Studio'); + expect(result[2].id).toBe('github-copilot'); + expect(result[2].name).toBe('GitHub Copilot'); + }); + }); + + // ======================================================================= + // Duplicate Aliases + // ======================================================================= + + describe('Duplicate Aliases', () => { + it('should map provider aliases to the same ID', () => { + // Test copilot variants + const copilot1 = parseProviders('● copilot oauth'); + const copilot2 = parseProviders('● github copilot oauth'); + expect(copilot1[0].id).toBe('github-copilot'); + expect(copilot2[0].id).toBe('github-copilot'); + + // Test bedrock variants + const bedrock1 = parseProviders('● bedrock oauth'); + const bedrock2 = parseProviders('● amazon bedrock oauth'); + expect(bedrock1[0].id).toBe('amazon-bedrock'); + expect(bedrock2[0].id).toBe('amazon-bedrock'); + + // Test lmstudio variants + const lm1 = parseProviders('● lmstudio api'); + const lm2 = parseProviders('● lm studio api'); + expect(lm1[0].id).toBe('lmstudio'); + expect(lm2[0].id).toBe('lmstudio'); + }); + }); + + // ======================================================================= + // Authentication Methods + // ======================================================================= + + describe('Authentication Methods', () => { + it('should detect oauth and api_key auth methods', () => { + const output = '● anthropic oauth\n● openai api\n● google api_key'; + const result = parseProviders(output); + + expect(result[0].authMethod).toBe('oauth'); + expect(result[1].authMethod).toBe('api_key'); + expect(result[2].authMethod).toBe('api_key'); + }); + + it('should set authenticated to true and handle case-insensitive auth methods', () => { + const output = '● anthropic OAuth\n● openai API'; + const result = parseProviders(output); + + expect(result[0].authenticated).toBe(true); + expect(result[0].authMethod).toBe('oauth'); + expect(result[1].authenticated).toBe(true); + expect(result[1].authMethod).toBe('api_key'); + }); + + it('should return undefined authMethod for unknown auth types', () => { + const output = '● anthropic unknown-auth'; + const result = parseProviders(output); + + expect(result[0].authenticated).toBe(true); + expect(result[0].authMethod).toBeUndefined(); + }); + }); + + // ======================================================================= + // ANSI Escape Sequences + // ======================================================================= + + describe('ANSI Escape Sequences', () => { + it('should strip ANSI color codes from output', () => { + const output = '\x1b[32m● anthropic oauth\x1b[0m'; + const result = parseProviders(output); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('anthropic'); + expect(result[0].name).toBe('anthropic'); + }); + + it('should handle complex ANSI sequences and codes in provider names', () => { + const output = + '\x1b[1;32m●\x1b[0m \x1b[33mgit\x1b[32mhub\x1b[0m copilot\x1b[0m \x1b[36moauth\x1b[0m'; + const result = parseProviders(output); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('github-copilot'); + }); + }); + + // ======================================================================= + // Edge Cases + // ======================================================================= + + describe('Edge Cases', () => { + it('should return empty array for empty output or no ● symbols', () => { + expect(parseProviders('')).toEqual([]); + expect(parseProviders('anthropic oauth\nopenai api')).toEqual([]); + expect(parseProviders('No authenticated providers')).toEqual([]); + }); + + it('should skip malformed lines with ● but insufficient content', () => { + const output = '●\n● \n● anthropic\n● openai api'; + const result = parseProviders(output); + + // Only the last line has both provider name and auth method + expect(result).toHaveLength(1); + expect(result[0].id).toBe('openai'); + }); + + it('should use fallback for unknown providers (spaces to hyphens)', () => { + const output = '● unknown provider name oauth'; + const result = parseProviders(output); + + expect(result[0].id).toBe('unknown-provider-name'); + expect(result[0].name).toBe('unknown provider name'); + }); + + it('should handle extra whitespace and mixed case', () => { + const output = '● AnThRoPiC oauth'; + const result = parseProviders(output); + + expect(result[0].id).toBe('anthropic'); + expect(result[0].name).toBe('AnThRoPiC'); + }); + + it('should handle multiple ● symbols on same line', () => { + const output = '● ● anthropic oauth'; + const result = parseProviders(output); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('anthropic'); + }); + + it('should handle different newline formats and trailing newlines', () => { + const outputUnix = '● anthropic oauth\n● openai api'; + const outputWindows = '● anthropic oauth\r\n● openai api\r\n\r\n'; + + const resultUnix = parseProviders(outputUnix); + const resultWindows = parseProviders(outputWindows); + + expect(resultUnix).toHaveLength(2); + expect(resultWindows).toHaveLength(2); + }); + + it('should handle provider names with numbers and special characters', () => { + const output = '● gpt-4o api'; + const result = parseProviders(output); + + expect(result[0].id).toBe('gpt-4o'); + expect(result[0].name).toBe('gpt-4o'); + }); + }); + + // ======================================================================= + // Real-world CLI Output + // ======================================================================= + + describe('Real-world CLI Output', () => { + it('should parse CLI output with box drawing characters and decorations', () => { + const output = `┌─────────────────────────────────────────────────┐ +│ Authenticated Providers │ +├─────────────────────────────────────────────────┤ +● anthropic oauth +● openai api +└─────────────────────────────────────────────────┘`; + + const result = parseProviders(output); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('anthropic'); + expect(result[1].id).toBe('openai'); + }); + + it('should parse output with ANSI colors and box characters', () => { + const output = `\x1b[1m┌─────────────────────────────────────────────────┐\x1b[0m +\x1b[1m│ Authenticated Providers │\x1b[0m +\x1b[1m├─────────────────────────────────────────────────┤\x1b[0m +\x1b[32m●\x1b[0m \x1b[33manthropic\x1b[0m \x1b[36moauth\x1b[0m +\x1b[32m●\x1b[0m \x1b[33mgoogle\x1b[0m \x1b[36mapi\x1b[0m +\x1b[1m└─────────────────────────────────────────────────┘\x1b[0m`; + + const result = parseProviders(output); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('anthropic'); + expect(result[1].id).toBe('google'); + }); + + it('should handle "no authenticated providers" message', () => { + const output = `┌─────────────────────────────────────────────────┐ +│ No authenticated providers found │ +└─────────────────────────────────────────────────┘`; + + const result = parseProviders(output); + expect(result).toEqual([]); + }); + }); + }); });