diff --git a/.docker/selfhost/schema.json b/.docker/selfhost/schema.json index b93fee188e..9ac0395aad 100644 --- a/.docker/selfhost/schema.json +++ b/.docker/selfhost/schema.json @@ -1163,6 +1163,11 @@ "description": "Whether workspace BYOK custom endpoints are accepted.\n@default false", "default": false }, + "byok.allowPrivateEndpoint": { + "type": "boolean", + "description": "Whether workspace BYOK custom endpoints may resolve to private network targets. Enabling this allows workspace owners and admins to send provider probe requests to the private network.\n@default false", + "default": false + }, "providers.profiles": { "type": "array", "description": "The profile list for copilot providers.\n@default []", diff --git a/packages/backend/server/src/__tests__/copilot/byok-probe.spec.ts b/packages/backend/server/src/__tests__/copilot/byok-probe.spec.ts new file mode 100644 index 0000000000..70a4e4db5f --- /dev/null +++ b/packages/backend/server/src/__tests__/copilot/byok-probe.spec.ts @@ -0,0 +1,53 @@ +import test from 'ava'; +import Sinon from 'sinon'; + +import type { safeFetch } from '../../base'; +import { + PROVIDER_PROBE_MAX_BYTES, + runProviderProbe, +} from '../../plugins/copilot/byok/probe'; +import { ByokProvider } from '../../plugins/copilot/byok/types'; + +test('provider probe allows model responses and explicitly configured private targets', async t => { + const fetch = Sinon.stub< + Parameters, + ReturnType + >().resolves(new Response('{}', { status: 200 })); + + await runProviderProbe( + fetch, + ByokProvider.openai, + 'secret', + 'http://provider.internal/v1', + true + ); + + t.is(fetch.firstCall.args[0], 'http://provider.internal/v1/models'); + t.deepEqual(fetch.firstCall.args[2], { + timeoutMs: 10_000, + maxRedirects: 3, + maxBytes: PROVIDER_PROBE_MAX_BYTES, + allowedHeaders: ['Authorization'], + allowHttp: true, + allowPrivateTargetOrigin: true, + }); + t.true(PROVIDER_PROBE_MAX_BYTES >= 64 * 1024); +}); + +test('provider probe keeps private targets blocked by default', async t => { + const fetch = Sinon.stub< + Parameters, + ReturnType + >().resolves(new Response('{}', { status: 200 })); + + await runProviderProbe( + fetch, + ByokProvider.gemini, + 'secret', + 'https://provider.example/v1beta', + false + ); + + t.false(fetch.firstCall.args[2]?.allowHttp); + t.false(fetch.firstCall.args[2]?.allowPrivateTargetOrigin); +}); diff --git a/packages/backend/server/src/plugins/copilot/byok/probe.ts b/packages/backend/server/src/plugins/copilot/byok/probe.ts new file mode 100644 index 0000000000..e63a621ff3 --- /dev/null +++ b/packages/backend/server/src/plugins/copilot/byok/probe.ts @@ -0,0 +1,94 @@ +import { BadRequestException } from '@nestjs/common'; + +import type { safeFetch } from '../../../base'; +import { ByokProvider } from './types'; + +const TEST_TIMEOUT_MS = 10_000; +export const PROVIDER_PROBE_MAX_BYTES = 1024 * 1024; + +type ProbeFetch = typeof safeFetch; + +export async function runProviderProbe( + probeFetch: ProbeFetch, + provider: ByokProvider, + apiKey: string, + endpoint: string | null, + allowPrivateEndpoint: boolean +) { + const request = buildProbeRequest(provider, apiKey, endpoint); + const response = await probeFetch( + request.url, + { + method: request.method, + headers: request.headers, + }, + { + timeoutMs: TEST_TIMEOUT_MS, + maxRedirects: 3, + maxBytes: PROVIDER_PROBE_MAX_BYTES, + allowedHeaders: Object.keys(request.headers), + allowHttp: endpoint?.startsWith('http:') ?? false, + allowPrivateTargetOrigin: allowPrivateEndpoint, + } + ); + if (!response.ok) { + throw new BadRequestException(providerProbeFailureMessage(response.status)); + } +} + +function buildProbeRequest( + provider: ByokProvider, + apiKey: string, + endpoint: string | null +): { + method: 'GET'; + url: string; + headers: Record; +} { + switch (provider) { + case ByokProvider.openai: + return { + method: 'GET', + url: `${endpoint ?? 'https://api.openai.com/v1'}/models`, + headers: { Authorization: `Bearer ${apiKey}` }, + }; + case ByokProvider.anthropic: + return { + method: 'GET', + url: `${endpoint ?? 'https://api.anthropic.com/v1'}/models`, + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + }; + case ByokProvider.gemini: + return { + method: 'GET', + url: `${endpoint ?? 'https://generativelanguage.googleapis.com/v1beta'}/models`, + headers: { 'x-goog-api-key': apiKey }, + }; + case ByokProvider.fal: + return { + method: 'GET', + url: 'https://api.fal.ai/v1/models?limit=10', + headers: { Authorization: `Key ${apiKey}` }, + }; + } +} + +function providerProbeFailureMessage(status: number) { + switch (status) { + case 401: + return 'Provider rejected the BYOK key.'; + case 403: + return 'Provider rejected the BYOK key permissions.'; + case 404: + return 'Provider probe endpoint was not found.'; + case 429: + return 'Provider rate limit exceeded while testing the key.'; + default: + return status >= 500 + ? 'Provider service is unavailable.' + : `Provider key test failed with HTTP ${status}.`; + } +} diff --git a/packages/backend/server/src/plugins/copilot/byok/resolver.ts b/packages/backend/server/src/plugins/copilot/byok/resolver.ts index ec2246bba9..371256d43e 100644 --- a/packages/backend/server/src/plugins/copilot/byok/resolver.ts +++ b/packages/backend/server/src/plugins/copilot/byok/resolver.ts @@ -117,6 +117,9 @@ class WorkspaceByokSettingsType { @Field(() => Boolean) customEndpointSupported!: boolean; + @Field(() => Boolean) + privateEndpointSupported!: boolean; + @Field(() => Boolean) hasAiPlan!: boolean; diff --git a/packages/backend/server/src/plugins/copilot/byok/service.ts b/packages/backend/server/src/plugins/copilot/byok/service.ts index 446971dfc7..d2f5b12c1f 100644 --- a/packages/backend/server/src/plugins/copilot/byok/service.ts +++ b/packages/backend/server/src/plugins/copilot/byok/service.ts @@ -13,6 +13,7 @@ import { import { Models } from '../../../models'; import type { CopilotProviderProfile } from '../config'; import { ByokEntitlementPolicy } from './policy'; +import { runProviderProbe } from './probe'; import { BYOK_ALLOWED_PROVIDERS, type ByokFeatureKind, @@ -27,7 +28,6 @@ import { const LOCAL_LEASE_TTL_MS = 10 * 60 * 1000; const BYOK_PROFILE_PRIORITY_BASE = 10_000; const SERVER_PROFILE_PRIORITY_OFFSET = 2_000; -const TEST_TIMEOUT_MS = 10_000; export type ByokProviderRequestContext = { userId?: string; @@ -71,6 +71,7 @@ export type ByokSettings = { allowedProviders: ByokProvider[]; localStorageSupported: boolean; customEndpointSupported: boolean; + privateEndpointSupported: boolean; hasAiPlan: boolean; warnings: Array<{ featureKind: string; @@ -124,6 +125,13 @@ export class ByokService { return env.selfhosted && this.config.copilot.byok.allowCustomEndpoint; } + get privateEndpointSupported() { + return ( + this.customEndpointSupported && + this.config.copilot.byok.allowPrivateEndpoint + ); + } + async getSettings( workspaceId: string, userId?: string @@ -139,6 +147,7 @@ export class ByokService { allowedProviders: [...BYOK_ALLOWED_PROVIDERS], localStorageSupported: false, customEndpointSupported: this.customEndpointSupported, + privateEndpointSupported: this.privateEndpointSupported, hasAiPlan: await this.entitlement.hasAiPlan(userId), warnings: [], }; @@ -158,6 +167,7 @@ export class ByokService { allowedProviders: [...BYOK_ALLOWED_PROVIDERS], localStorageSupported: false, customEndpointSupported: this.customEndpointSupported, + privateEndpointSupported: this.privateEndpointSupported, hasAiPlan: await this.entitlement.hasAiPlan(userId), warnings: [], }; @@ -178,6 +188,7 @@ export class ByokService { allowedProviders: [...BYOK_ALLOWED_PROVIDERS], localStorageSupported: false, customEndpointSupported: this.customEndpointSupported, + privateEndpointSupported: this.privateEndpointSupported, hasAiPlan: await this.entitlement.hasAiPlan(userId), warnings: this.buildWarnings(keys), }; @@ -332,7 +343,13 @@ export class ByokService { } try { - await this.runProviderProbe(input.provider, apiKey, endpoint); + await runProviderProbe( + this.probeFetch, + input.provider, + apiKey, + endpoint, + this.privateEndpointSupported + ); if (input.configId && input.storage === ByokKeyStorage.server) { await this.models.copilotWorkspaceByokConfig.markValidated( input.workspaceId, @@ -760,68 +777,6 @@ export class ByokService { } } - private async runProviderProbe( - provider: ByokProvider, - apiKey: string, - endpoint: string | null - ) { - const request = this.buildProbeRequest(provider, apiKey, endpoint); - const response = await this.probeFetch( - request.url, - { - method: request.method, - headers: request.headers as unknown as Record, - }, - { - timeoutMs: TEST_TIMEOUT_MS, - maxRedirects: 3, - maxBytes: 1024, - allowedHeaders: Object.keys(request.headers), - } - ); - if (!response.ok) { - throw new BadRequestException( - this.providerProbeFailureMessage(response.status) - ); - } - } - - private buildProbeRequest( - provider: ByokProvider, - apiKey: string, - endpoint: string | null - ) { - switch (provider) { - case ByokProvider.openai: - return { - method: 'GET', - url: `${endpoint ?? 'https://api.openai.com/v1'}/models`, - headers: { Authorization: `Bearer ${apiKey}` }, - }; - case ByokProvider.anthropic: - return { - method: 'GET', - url: `${endpoint ?? 'https://api.anthropic.com/v1'}/models`, - headers: { - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', - }, - }; - case ByokProvider.gemini: - return { - method: 'GET', - url: `${endpoint ?? 'https://generativelanguage.googleapis.com/v1beta'}/models`, - headers: { 'x-goog-api-key': apiKey }, - }; - case ByokProvider.fal: - return { - method: 'GET', - url: 'https://api.fal.ai/v1/models?limit=10', - headers: { Authorization: `Key ${apiKey}` }, - }; - } - } - private sanitizeError(error: unknown) { if (error instanceof Error && error.name === 'AbortError') { return 'Provider key test timed out.'; @@ -832,23 +787,6 @@ export class ByokService { return 'Provider request failed.'; } - private providerProbeFailureMessage(status: number) { - switch (status) { - case 401: - return 'Provider rejected the BYOK key.'; - case 403: - return 'Provider rejected the BYOK key permissions.'; - case 404: - return 'Provider probe endpoint was not found.'; - case 429: - return 'Provider rate limit exceeded while testing the key.'; - default: - return status >= 500 - ? 'Provider service is unavailable.' - : `Provider key test failed with HTTP ${status}.`; - } - } - private workspaceHash(workspaceId: string) { return createHash('sha256').update(workspaceId).digest('hex').slice(0, 12); } diff --git a/packages/backend/server/src/plugins/copilot/config.ts b/packages/backend/server/src/plugins/copilot/config.ts index c55772428b..c71087467b 100644 --- a/packages/backend/server/src/plugins/copilot/config.ts +++ b/packages/backend/server/src/plugins/copilot/config.ts @@ -191,6 +191,7 @@ declare global { Array<'openai' | 'anthropic' | 'gemini' | 'fal'> >; allowCustomEndpoint: ConfigItem; + allowPrivateEndpoint: ConfigItem; }; unsplash: ConfigItem<{ key: string; @@ -234,6 +235,11 @@ defineModuleConfig('copilot', { default: false, shape: z.boolean(), }, + 'byok.allowPrivateEndpoint': { + desc: 'Whether workspace BYOK custom endpoints may resolve to private network targets. Enabling this allows workspace owners and admins to send provider probe requests to the private network.', + default: false, + shape: z.boolean(), + }, 'providers.profiles': { desc: 'The profile list for copilot providers.', default: [], diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 57e7692813..27015cb17d 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -2796,6 +2796,7 @@ type WorkspaceByokSettingsType { keys: [WorkspaceByokKeyConfigType!]! localEntitled: Boolean! localStorageSupported: Boolean! + privateEndpointSupported: Boolean! serverEntitled: Boolean! warnings: [WorkspaceByokCapabilityWarningType!]! workspaceId: String! diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index 6b5d87472f..7ff82dbe18 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -3060,6 +3060,7 @@ export const workspaceByokSettingsQuery = { allowedProviders localStorageSupported customEndpointSupported + privateEndpointSupported hasAiPlan keys { id diff --git a/packages/common/graphql/src/graphql/workspace-byok-settings.gql b/packages/common/graphql/src/graphql/workspace-byok-settings.gql index bc3b13d057..68e66f09f8 100644 --- a/packages/common/graphql/src/graphql/workspace-byok-settings.gql +++ b/packages/common/graphql/src/graphql/workspace-byok-settings.gql @@ -10,6 +10,7 @@ query workspaceByokSettings($id: String!, $from: DateTime!, $to: DateTime!) { allowedProviders localStorageSupported customEndpointSupported + privateEndpointSupported hasAiPlan keys { id diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index b1cfbfc867..5a1bffae09 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -3569,6 +3569,7 @@ export interface WorkspaceByokSettingsType { keys: Array; localEntitled: Scalars['Boolean']['output']; localStorageSupported: Scalars['Boolean']['output']; + privateEndpointSupported: Scalars['Boolean']['output']; serverEntitled: Scalars['Boolean']['output']; warnings: Array; workspaceId: Scalars['String']['output']; @@ -7784,6 +7785,7 @@ export type WorkspaceByokSettingsQuery = { allowedProviders: Array; localStorageSupported: boolean; customEndpointSupported: boolean; + privateEndpointSupported: boolean; hasAiPlan: boolean; keys: Array<{ __typename?: 'WorkspaceByokKeyConfigType'; diff --git a/packages/frontend/admin/src/config.json b/packages/frontend/admin/src/config.json index f4ced9555c..f6d1a87ce0 100644 --- a/packages/frontend/admin/src/config.json +++ b/packages/frontend/admin/src/config.json @@ -381,6 +381,10 @@ "type": "Boolean", "desc": "Whether workspace BYOK custom endpoints are accepted." }, + "byok.allowPrivateEndpoint": { + "type": "Boolean", + "desc": "Whether workspace BYOK custom endpoints may resolve to private network targets. Enabling this allows workspace owners and admins to send provider probe requests to the private network." + }, "providers.profiles": { "type": "Array", "desc": "The profile list for copilot providers." diff --git a/packages/frontend/core/src/__tests__/auth-native-sign-in.spec.ts b/packages/frontend/core/src/__tests__/auth-native-sign-in.spec.ts new file mode 100644 index 0000000000..b413037ac7 --- /dev/null +++ b/packages/frontend/core/src/__tests__/auth-native-sign-in.spec.ts @@ -0,0 +1,86 @@ +import { AuthSession } from '@affine/core/modules/cloud/entities/session'; +import { AuthService } from '@affine/core/modules/cloud/services/auth'; +import { FetchService } from '@affine/core/modules/cloud/services/fetch'; +import { ServerService } from '@affine/core/modules/cloud/services/server'; +import { AuthStore } from '@affine/core/modules/cloud/stores/auth'; +import { GlobalDialogService } from '@affine/core/modules/dialogs/services/dialog'; +import { NbstoreService } from '@affine/core/modules/storage'; +import { UrlService } from '@affine/core/modules/url/services/url'; +import { ServerDeploymentType } from '@affine/graphql'; +import { Framework } from '@toeverything/infra'; +import { of } from 'rxjs'; +import { describe, expect, test, vi } from 'vitest'; + +describe('AuthService native password sign-in', () => { + test.each(['android', 'ios'])( + 'waits for the %s session bootstrap before completing', + async () => { + let resolveSession!: () => void; + const revalidateOnce = vi.fn( + () => + new Promise(resolve => { + resolveSession = resolve; + }) + ); + const signInPassword = vi.fn().mockResolvedValue(undefined); + const framework = new Framework(); + + framework.entity( + AuthSession, + () => + ({ + account$: of(null), + revalidate: vi.fn(), + revalidateOnce, + }) as any + ); + framework.service(FetchService, { fetch: vi.fn() } as any); + framework.store(AuthStore, { + signInPassword, + setCachedSignInUser: vi.fn(), + } as any); + framework.service(UrlService, { getClientScheme: () => null } as any); + framework.service(GlobalDialogService, { open: vi.fn() } as any); + framework.service(NbstoreService, { + realtime: { subscribe: () => of() }, + } as any); + framework.service(ServerService, { + server: { + ['config$']: { + value: { + type: ServerDeploymentType.Selfhosted, + version: '0.27.2', + }, + }, + }, + } as any); + framework.service(AuthService, [ + FetchService, + AuthStore, + UrlService, + GlobalDialogService, + NbstoreService, + ServerService, + ]); + + const auth = framework.provider().get(AuthService); + let completed = false; + const signInPromise = auth + .signInPassword({ + email: 'user@example.com', + password: 'password', + }) + .then(() => { + completed = true; + }); + + await vi.waitFor(() => expect(revalidateOnce).toHaveBeenCalledOnce()); + expect(completed).toBe(false); + resolveSession(); + await signInPromise; + + expect(signInPassword).toHaveBeenCalledOnce(); + expect(completed).toBe(true); + } + ); +}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/add-key-modal.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/add-key-modal.tsx index 12098b1066..6a9bb64a73 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/add-key-modal.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/add-key-modal.tsx @@ -11,7 +11,13 @@ import { useCallback, useEffect, useState } from 'react'; import { logByokError } from './errors'; import * as styles from './index.css'; import { readLocalKeys, upsertLocalKey } from './local-storage'; -import { byokT, providerLabels, storageLabel } from './metadata'; +import { + byokT, + endpointHintKey, + providerLabels, + shouldShowEndpoint, + storageLabel, +} from './metadata'; import type { ByokKey, ByokSettings, @@ -32,6 +38,7 @@ export const AddKeyModal = ({ localStorageSupported, canAddServerKey, canAddLocalKey, + isSelfHosted, gql, }: { workspaceId: string; @@ -45,6 +52,7 @@ export const AddKeyModal = ({ localStorageSupported: boolean; canAddServerKey: boolean; canAddLocalKey: boolean; + isSelfHosted: boolean; gql?: GqlFn; }) => { const t = useI18n(); @@ -61,6 +69,10 @@ export const AddKeyModal = ({ editingKey?.storage === ByokKeyStorage.server && editingKey.provider === provider; const canTest = !!apiKey || canTestStoredConfig; + const endpointHint = endpointHintKey( + settings.customEndpointSupported, + settings.privateEndpointSupported + ); useEffect(() => { if (!open) { @@ -275,19 +287,23 @@ export const AddKeyModal = ({ }} type="password" /> - - {settings.customEndpointSupported ? ( - {' '} + {shouldShowEndpoint(isSelfHosted, settings.customEndpointSupported) ? ( + ) : null}
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.css.ts index dcfc2edbb0..68cf41b0ef 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.css.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.css.ts @@ -186,11 +186,19 @@ export const field = style({ height: '3em', }); +export const endpointField = style([field, { height: 'auto' }]); + export const label = style({ fontSize: cssVar('fontXs'), color: cssVarV2('text/secondary'), }); +export const fieldHint = style({ + fontSize: cssVar('fontXs'), + lineHeight: '18px', + color: cssVarV2('text/secondary'), +}); + export const input = style({ height: 32, width: '100%', diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.spec.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.spec.tsx index 9354f9f5f2..b27dfdd02f 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.spec.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.spec.tsx @@ -47,6 +47,10 @@ const ByokKeyTestStatus = vi.hoisted(() => ({ passed: 'passed', failed: 'failed', })); +const ServerDeploymentType = vi.hoisted(() => ({ + Affine: 'Affine', + Selfhosted: 'Selfhosted', +})); const workspaceByokSettingsQuery = vi.hoisted(() => Symbol('workspaceByokSettingsQuery') @@ -130,6 +134,7 @@ vi.mock('@affine/graphql', () => ({ ByokKeyStorage, ByokKeyTestStatus, ByokProvider, + ServerDeploymentType, clearWorkspaceByokConfigsMutation, deleteWorkspaceByokConfigMutation, testWorkspaceByokConfigMutation, @@ -220,6 +225,7 @@ vi.mock('@toeverything/infra', async importOriginal => { if (token === WorkspaceServerServiceToken) { return { server: { + ['config$']: { value: { type: ServerDeploymentType.Affine } }, gql: gqlMock, }, }; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.tsx index 063269ca59..a75c803df4 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.tsx @@ -10,6 +10,7 @@ import { clearWorkspaceByokConfigsMutation as clearByokMutation, deleteWorkspaceByokConfigMutation as deleteByokMutation, type GraphQLQuery, + ServerDeploymentType, workspaceByokSettingsQuery as byokSettingsQuery, } from '@affine/graphql'; import { useI18n } from '@affine/i18n'; @@ -114,6 +115,9 @@ export const WorkspaceByokSetting = () => { (settings?.localEntitled ?? false) && (settings?.localStorageSupported ?? false); const canManageKeys = canAddServerKey || canAddLocalKey; + const isSelfHosted = + workspaceServer.server?.config$.value.type === + ServerDeploymentType.Selfhosted; const clearAll = useCallback(async () => { if (!settings) { @@ -356,6 +360,7 @@ export const WorkspaceByokSetting = () => { localStorageSupported={settings.localStorageSupported} canAddServerKey={canAddServerKey} canAddLocalKey={canAddLocalKey} + isSelfHosted={isSelfHosted} gql={workspaceServer.server?.gql as GqlFn | undefined} /> diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/metadata.spec.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/metadata.spec.ts new file mode 100644 index 0000000000..530adaa79e --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/metadata.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'vitest'; + +import { endpointHintKey, shouldShowEndpoint } from './metadata'; + +describe('endpointHintKey', () => { + test.each([ + [false, false, 'endpoint.custom-disabled'], + [true, false, 'endpoint.private-disabled'], + [true, true, null], + ] as const)( + 'maps custom=%s private=%s to %s', + (customEndpointSupported, privateEndpointSupported, expected) => { + expect( + endpointHintKey(customEndpointSupported, privateEndpointSupported) + ).toBe(expected); + } + ); +}); + +describe('shouldShowEndpoint', () => { + test.each([ + [true, false, true], + [true, true, true], + [false, true, true], + [false, false, false], + ])( + 'self-hosted %s with custom endpoint support %s returns %s', + (isSelfHosted, customEndpointSupported, expected) => { + expect(shouldShowEndpoint(isSelfHosted, customEndpointSupported)).toBe( + expected + ); + } + ); +}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/metadata.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/metadata.ts index 966e2eb931..b8b7d48494 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/metadata.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/metadata.ts @@ -24,6 +24,26 @@ export function storageLabel(t: I18nInstance, storage: ByokStorage) { : byokT(t, 'storage.server'); } +export function endpointHintKey( + customEndpointSupported: boolean, + privateEndpointSupported: boolean +) { + if (!customEndpointSupported) { + return 'endpoint.custom-disabled'; + } + if (!privateEndpointSupported) { + return 'endpoint.private-disabled'; + } + return null; +} + +export function shouldShowEndpoint( + isSelfHosted: boolean, + customEndpointSupported: boolean +) { + return isSelfHosted || customEndpointSupported; +} + export function capabilitiesFor(provider: ByokProvider, storage: ByokStorage) { switch (provider) { case ByokProvider.openai: diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/types.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/types.ts index 47b5f4a916..ea5e651bc2 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/types.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/types.ts @@ -53,6 +53,7 @@ export type ByokSettings = { allowedProviders: ByokProvider[]; localStorageSupported: boolean; customEndpointSupported: boolean; + privateEndpointSupported: boolean; hasAiPlan: boolean; warnings: Array<{ featureKind: string; diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 56c80123b2..9c07742f4b 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -6140,6 +6140,14 @@ export function useAFFiNEI18N(): { * `Endpoint` */ ["com.affine.settings.workspace.byok.field.endpoint"](): string; + /** + * `Custom endpoints are disabled by the server administrator. In Self-hosted Admin, enable copilot.byok.allowCustomEndpoint.` + */ + ["com.affine.settings.workspace.byok.endpoint.custom-disabled"](): string; + /** + * `Private network endpoints additionally require the server administrator to enable copilot.byok.allowPrivateEndpoint.` + */ + ["com.affine.settings.workspace.byok.endpoint.private-disabled"](): string; /** * `Primary` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index ee7152ba43..40a4a9bf8f 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1532,6 +1532,8 @@ "com.affine.settings.workspace.byok.field.storage": "Key storage", "com.affine.settings.workspace.byok.field.api-key": "API key", "com.affine.settings.workspace.byok.field.endpoint": "Endpoint", + "com.affine.settings.workspace.byok.endpoint.custom-disabled": "Custom endpoints are disabled by the server administrator. In Self-hosted Admin, enable copilot.byok.allowCustomEndpoint.", + "com.affine.settings.workspace.byok.endpoint.private-disabled": "Private network endpoints additionally require the server administrator to enable copilot.byok.allowPrivateEndpoint.", "com.affine.settings.workspace.byok.placeholder.key-name": "Primary", "com.affine.settings.workspace.byok.placeholder.description": "Workspace fallback key", "com.affine.settings.workspace.byok.action.add-key": "Add key", diff --git a/packages/frontend/i18n/src/resources/zh-Hans.json b/packages/frontend/i18n/src/resources/zh-Hans.json index f909dc5ab9..787c28b04a 100644 --- a/packages/frontend/i18n/src/resources/zh-Hans.json +++ b/packages/frontend/i18n/src/resources/zh-Hans.json @@ -1532,6 +1532,8 @@ "com.affine.settings.workspace.byok.field.storage": "密钥存储", "com.affine.settings.workspace.byok.field.api-key": "API key", "com.affine.settings.workspace.byok.field.endpoint": "Endpoint", + "com.affine.settings.workspace.byok.endpoint.custom-disabled": "服务器管理员已禁用自定义端点。请在自托管管理后台启用 copilot.byok.allowCustomEndpoint。", + "com.affine.settings.workspace.byok.endpoint.private-disabled": "私有网络端点还需要服务器管理员启用 copilot.byok.allowPrivateEndpoint。", "com.affine.settings.workspace.byok.placeholder.key-name": "Primary", "com.affine.settings.workspace.byok.placeholder.description": "工作区回退密钥", "com.affine.settings.workspace.byok.action.add-key": "添加密钥",