diff --git a/apps/desktop/src/renderer/src/components/AddCustomProviderModal.test.tsx b/apps/desktop/src/renderer/src/components/AddCustomProviderModal.test.tsx new file mode 100644 index 00000000..7698ad33 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/AddCustomProviderModal.test.tsx @@ -0,0 +1,39 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import { AddCustomProviderModal } from './AddCustomProviderModal'; + +vi.mock('@open-codesign/i18n', () => ({ + useT: () => (key: string) => key, +})); + +describe('AddCustomProviderModal', () => { + it('shows the compatibility warning for editable custom endpoints', () => { + const html = renderToStaticMarkup( + undefined} onClose={() => undefined} />, + ); + + expect(html).toContain('settings.providers.custom.compatibilityHintTitle'); + expect(html).toContain('settings.providers.custom.compatibilityHintBody'); + }); + + it('hides the compatibility warning when editing a locked builtin endpoint', () => { + const html = renderToStaticMarkup( + undefined} + onClose={() => undefined} + editTarget={{ + id: 'anthropic', + name: 'Anthropic', + baseUrl: 'https://api.anthropic.com', + wire: 'anthropic', + defaultModel: 'claude-sonnet-4-5', + builtin: true, + lockEndpoint: true, + }} + />, + ); + + expect(html).not.toContain('settings.providers.custom.compatibilityHintTitle'); + expect(html).not.toContain('settings.providers.custom.compatibilityHintBody'); + }); +}); diff --git a/apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx b/apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx index 32eb7abe..f706e907 100644 --- a/apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx +++ b/apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx @@ -326,6 +326,17 @@ export function AddCustomProviderModal({ placeholder="https://api.example.com/v1" disabled={lockEndpoint} /> + {!lockEndpoint && ( +
+
+ + {t('settings.providers.custom.compatibilityHintTitle')} +
+

+ {t('settings.providers.custom.compatibilityHintBody')} +

+
+ )} diff --git a/apps/desktop/src/renderer/src/components/ConnectionDiagnosticPanel.test.ts b/apps/desktop/src/renderer/src/components/ConnectionDiagnosticPanel.test.ts index e18b7153..b9a15681 100644 --- a/apps/desktop/src/renderer/src/components/ConnectionDiagnosticPanel.test.ts +++ b/apps/desktop/src/renderer/src/components/ConnectionDiagnosticPanel.test.ts @@ -8,7 +8,7 @@ vi.mock('../store', () => ({ useCodesignStore: () => vi.fn(), })); -import { isAbsoluteHttpUrl } from './ConnectionDiagnosticPanel'; +import { isAbsoluteHttpUrl, shouldShowGatewayAllowlistHint } from './ConnectionDiagnosticPanel'; describe('isAbsoluteHttpUrl', () => { it('rejects an empty string so /v1 quick-fix cannot produce a bare "/v1"', () => { @@ -28,3 +28,33 @@ describe('isAbsoluteHttpUrl', () => { expect(isAbsoluteHttpUrl(' https://api.example.com ')).toBe(true); }); }); + +describe('shouldShowGatewayAllowlistHint', () => { + it('shows the hint for 400-class compatibility failures on third-party gateways', () => { + expect(shouldShowGatewayAllowlistHint('400', 'https://relay.example.com/v1', undefined)).toBe( + true, + ); + expect( + shouldShowGatewayAllowlistHint( + '403', + 'https://relay.example.com/v1', + 'https://relay.example.com/v1/chat/completions', + ), + ).toBe(true); + expect(shouldShowGatewayAllowlistHint('PARSE', 'https://relay.example.com/v1')).toBe(true); + }); + + it('suppresses the hint for official providers and localhost proxies', () => { + expect(shouldShowGatewayAllowlistHint('400', 'https://api.openai.com/v1')).toBe(false); + expect(shouldShowGatewayAllowlistHint('403', 'https://api.anthropic.com')).toBe(false); + expect(shouldShowGatewayAllowlistHint('400', 'http://127.0.0.1:8317')).toBe(false); + }); + + it('suppresses the hint for unrelated error classes', () => { + expect(shouldShowGatewayAllowlistHint('404', 'https://relay.example.com/v1')).toBe(false); + expect(shouldShowGatewayAllowlistHint('429', 'https://relay.example.com/v1')).toBe(false); + expect(shouldShowGatewayAllowlistHint('ECONNREFUSED', 'https://relay.example.com/v1')).toBe( + false, + ); + }); +}); diff --git a/apps/desktop/src/renderer/src/components/ConnectionDiagnosticPanel.tsx b/apps/desktop/src/renderer/src/components/ConnectionDiagnosticPanel.tsx index 57b5b077..e834d254 100644 --- a/apps/desktop/src/renderer/src/components/ConnectionDiagnosticPanel.tsx +++ b/apps/desktop/src/renderer/src/components/ConnectionDiagnosticPanel.tsx @@ -11,6 +11,49 @@ export function isAbsoluteHttpUrl(value: string): boolean { return /^https?:\/\/\S+/i.test(value.trim()); } +const OFFICIAL_PROVIDER_HOST_SUFFIXES = [ + 'openai.com', + 'anthropic.com', + 'openrouter.ai', + 'deepseek.com', + 'googleapis.com', + 'google.com', + 'x.ai', + 'mistral.ai', + 'groq.com', + 'cerebras.ai', + 'amazonaws.com', + 'azure.com', +] as const; + +function hostnameFromUrl(value: string | undefined): string | null { + if (!value) return null; + try { + return new URL(value).hostname.toLowerCase(); + } catch { + return null; + } +} + +function isOfficialProviderHost(hostname: string | null): boolean { + if (!hostname) return false; + return OFFICIAL_PROVIDER_HOST_SUFFIXES.some( + (suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`), + ); +} + +export function shouldShowGatewayAllowlistHint( + errorCode: ErrorCode, + baseUrl: string, + attemptedUrl?: string, +): boolean { + const normalised = String(errorCode).toUpperCase(); + if (!['400', '401', '403', 'PARSE'].includes(normalised)) return false; + const hostname = hostnameFromUrl(attemptedUrl) ?? hostnameFromUrl(baseUrl); + if (!hostname || hostname === 'localhost' || hostname === '127.0.0.1') return false; + return !isOfficialProviderHost(hostname); +} + export interface ConnectionDiagnosticPanelProps { /** The error code returned by connection.test or generate */ errorCode: ErrorCode; @@ -58,6 +101,7 @@ export function ConnectionDiagnosticPanel({ ? fix.baseUrlTransform(baseUrl) : undefined; const canApplyFix = suggestedUrl !== undefined || fix?.externalUrl !== undefined; + const showGatewayAllowlistHint = shouldShowGatewayAllowlistHint(errorCode, baseUrl, attemptedUrl); function handleApplyFix() { if (suggestedUrl !== undefined) { @@ -137,6 +181,16 @@ export function ConnectionDiagnosticPanel({ {t('diagnostics.fix.addV1')}: {suggestedUrl}

)} + {showGatewayAllowlistHint && ( +
+

+ {t('diagnostics.gatewayAllowlistHintTitle')} +

+

+ {t('diagnostics.gatewayAllowlistHintBody')} +

+
+ )} {/* Actions */} diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 0f4ed923..d86773e6 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -216,6 +216,8 @@ "apiKey": "API Key", "apiKeyEditPlaceholder": "Leave empty to keep {{mask}}", "defaultModel": "Default model", + "compatibilityHintTitle": "Compatibility note", + "compatibilityHintBody": "Some coding plans, relay services, or OpenAI-compatible gateways only allow specific clients such as Claude Code, openclaw, or Hermes. Even if the API looks compatible, Open CoDesign may still be blocked by an app allowlist.", "switchToManual": "Enter manually", "switchToDropdown": "Pick from list", "discoveringModels": "Discovering models...", @@ -824,6 +826,8 @@ "testAgain": "Test again", "showLog": "Show full log", "showLogFailed": "Failed to open logs folder", + "gatewayAllowlistHintTitle": "This endpoint may restrict which apps can connect", + "gatewayAllowlistHintBody": "Some coding plans and protocol-conversion gateways keep an app allowlist. Even when they advertise an OpenAI-compatible API, they may still reject Open CoDesign unless this app is explicitly allowed.", "dismiss": "Dismiss", "report": { "title": "Report a bug", diff --git a/packages/i18n/src/locales/pt-BR.json b/packages/i18n/src/locales/pt-BR.json index 7eb0cae0..744236ae 100644 --- a/packages/i18n/src/locales/pt-BR.json +++ b/packages/i18n/src/locales/pt-BR.json @@ -213,6 +213,8 @@ "apiKey": "Chave de API", "apiKeyEditPlaceholder": "Deixe vazio para manter {{mask}}", "defaultModel": "Modelo padrão", + "compatibilityHintTitle": "Aviso de compatibilidade", + "compatibilityHintBody": "Alguns coding plans, relays e gateways compatíveis com OpenAI só permitem clientes específicos, como Claude Code, openclaw ou Hermes. Mesmo que a API pareça compatível, o Open CoDesign ainda pode ser bloqueado por uma lista de apps permitidos.", "test": "Testar conexão", "testOk": "OK — {{count}} modelos disponíveis", "save": "Salvar e continuar", @@ -806,6 +808,8 @@ "testAgain": "Testar novamente", "showLog": "Mostrar log completo", "showLogFailed": "Falha ao abrir a pasta de logs", + "gatewayAllowlistHintTitle": "Este endpoint pode restringir quais apps podem se conectar", + "gatewayAllowlistHintBody": "Alguns coding plans e gateways de conversão de protocolo mantêm uma lista de apps permitidos. Mesmo anunciando uma API compatível com OpenAI, eles ainda podem recusar o Open CoDesign se este app não estiver explicitamente liberado.", "dismiss": "Dispensar", "report": { "title": "Reportar um bug", diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index a0bdf354..2ab06b4b 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -216,6 +216,8 @@ "apiKey": "API Key", "apiKeyEditPlaceholder": "留空则保留 {{mask}}", "defaultModel": "默认模型", + "compatibilityHintTitle": "\u517c\u5bb9\u6027\u63d0\u793a", + "compatibilityHintBody": "\u90e8\u5206 coding plan\uff0c\u534f\u8bae\u8f6c\u6362\u670d\u52a1\u6216 OpenAI \u517c\u5bb9\u7f51\u5173\u53ea\u5141\u8bb8\u7279\u5b9a\u5ba2\u6237\u7aef\uff08\u5982 Claude Code\uff0copenclaw\uff0cHermes\uff09\u8bbf\u95ee\u3002\u5373\u4f7f API \u770b\u8d77\u6765\u517c\u5bb9\uff0cOpen CoDesign \u4e5f\u53ef\u80fd\u56e0\u4e3a\u5e94\u7528\u767d\u540d\u5355\u800c\u65e0\u6cd5\u4f7f\u7528\u3002", "switchToManual": "手动输入", "switchToDropdown": "从列表选择", "discoveringModels": "正在发现模型…", @@ -820,6 +822,8 @@ "testAgain": "再次测试", "showLog": "查看完整日志", "showLogFailed": "无法打开日志文件夹", + "gatewayAllowlistHintTitle": "\u8fd9\u4e2a\u7aef\u70b9\u53ef\u80fd\u9650\u5236\u53ef\u63a5\u5165\u7684\u5e94\u7528", + "gatewayAllowlistHintBody": "\u90e8\u5206 coding plan \u548c\u534f\u8bae\u8f6c\u6362\u7f51\u5173\u4f1a\u914d\u7f6e\u5e94\u7528\u767d\u540d\u5355\u3002\u5b83\u4eec\u5373\u4f7f\u58f0\u79f0\u63d0\u4f9b OpenAI \u517c\u5bb9 API\uff0c\u4e5f\u53ef\u80fd\u5728\u6ca1\u6709\u663e\u5f0f\u5141\u8bb8 Open CoDesign \u7684\u60c5\u51b5\u4e0b\u62d2\u7edd\u8bf7\u6c42\u3002", "dismiss": "关闭", "report": { "title": "上报问题",