Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(
<AddCustomProviderModal onSave={() => 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(
<AddCustomProviderModal
onSave={() => 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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,17 @@ export function AddCustomProviderModal({
placeholder="https://api.example.com/v1"
disabled={lockEndpoint}
/>
{!lockEndpoint && (
<div className="mt-2 rounded-[var(--radius-md)] border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-3 py-2 text-[var(--text-xs)] text-[var(--color-text-secondary)]">
<div className="flex items-center gap-1.5 font-medium text-[var(--color-text-primary)]">
<AlertCircle className="w-3.5 h-3.5 text-[var(--color-warning)]" />
<span>{t('settings.providers.custom.compatibilityHintTitle')}</span>
</div>
<p className="mt-1 leading-5">
{t('settings.providers.custom.compatibilityHintBody')}
</p>
</div>
)}
</Field>

<Field label={t('settings.providers.custom.apiKey')}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"', () => {
Expand All @@ -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,
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currently includes broad cloud domains (, ). That suppresses this hint for many third-party relays hosted on AWS/Azure, creating false negatives for the exact allowlist problem this UI is trying to explain. Please narrow this to provider-specific official endpoint patterns instead of whole-cloud suffixes.\n\n

] 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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -137,6 +181,16 @@ export function ConnectionDiagnosticPanel({
{t('diagnostics.fix.addV1')}: {suggestedUrl}
</p>
)}
{showGatewayAllowlistHint && (
<div className="mt-2 rounded-[var(--radius-md)] border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-3 py-2">
<p className="font-medium text-[var(--color-text-primary)]">
{t('diagnostics.gatewayAllowlistHintTitle')}
</p>
<p className="mt-1 text-[var(--text-xs)] leading-5">
{t('diagnostics.gatewayAllowlistHintBody')}
</p>
</div>
)}
</div>

{/* Actions */}
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "正在发现模型…",
Expand Down Expand Up @@ -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": "上报问题",
Expand Down
Loading