feat(auth): add remote API control config to gate third-party providers

This commit is contained in:
xixingde 2026-05-14 16:03:04 +08:00
parent a1dca02b55
commit 25ea4a0cf3
4 changed files with 200 additions and 67 deletions

View File

@ -1,6 +1,7 @@
import type { Command } from '../commands.js' import type { Command } from '../commands.js'
import type { LocalCommandCall } from '../types/command.js' import type { LocalCommandCall } from '../types/command.js'
import { getAPIProvider } from '../utils/model/providers.js' import { getAPIProvider } from '../utils/model/providers.js'
import { isThirdPartyApiEnabled } from '../services/apiControlConfig/index.js'
import { updateSettingsForSource } from '../utils/settings/settings.js' import { updateSettingsForSource } from '../utils/settings/settings.js'
import { getSettings_DEPRECATED } from '../utils/settings/settings.js' import { getSettings_DEPRECATED } from '../utils/settings/settings.js'
import { applyConfigEnvironmentVariables } from '../utils/managedEnv.js' import { applyConfigEnvironmentVariables } from '../utils/managedEnv.js'
@ -85,16 +86,11 @@ const call: LocalCommandCall = async (args, _context) => {
} }
} }
// Validate provider // Validate provider — dynamically filter based on remote API control config
const validProviders = [ const thirdPartyEnabled = isThirdPartyApiEnabled()
'anthropic', const validProviders = thirdPartyEnabled
'openai', ? ['anthropic', 'openai', 'gemini', 'grok', 'bedrock', 'vertex', 'foundry']
'gemini', : []
'grok',
'bedrock',
'vertex',
'foundry',
]
if (!validProviders.includes(arg)) { if (!validProviders.includes(arg)) {
return { return {
type: 'text', type: 'text',

View File

@ -19,6 +19,7 @@ import { Spinner } from './Spinner.js'
import TextInput from './TextInput.js' import TextInput from './TextInput.js'
import { useSetAppState } from '../state/AppState.js' import { useSetAppState } from '../state/AppState.js'
import { fi } from 'zod/v4/locales' import { fi } from 'zod/v4/locales'
import { isThirdPartyApiEnabled } from '../services/apiControlConfig/index.js'
type Props = { type Props = {
onDone(): void onDone(): void
@ -100,6 +101,13 @@ export function ConsoleOAuthFlow({
return { state: 'ready_to_start' } return { state: 'ready_to_start' }
} }
if (forceLoginMethod === 'claudeai' || forceLoginMethod === 'console') { if (forceLoginMethod === 'claudeai' || forceLoginMethod === 'console') {
// When third-party APIs are disabled by remote config, ignore
// forceLoginMethod and show the idle screen (CoStrict only).
// This prevents bypassing the isThirdPartyApiEnabled() gate
// through settings.forceLoginMethod.
if (!isThirdPartyApiEnabled()) {
return { state: 'idle' }
}
return { state: 'ready_to_start' } return { state: 'ready_to_start' }
} }
return { state: 'idle' } return { state: 'idle' }
@ -416,6 +424,7 @@ export function ConsoleOAuthFlow({
setOAuthStatus={setOAuthStatus} setOAuthStatus={setOAuthStatus}
setLoginWithClaudeAi={setLoginWithClaudeAi} setLoginWithClaudeAi={setLoginWithClaudeAi}
onDone={onDone} onDone={onDone}
setAppState={setAppState}
/> />
</Box> </Box>
</Box> </Box>
@ -437,6 +446,7 @@ type OAuthStatusMessageProps = {
handleSubmitCode: (value: string, url: string) => void handleSubmitCode: (value: string, url: string) => void
setOAuthStatus: (status: OAuthStatus) => void setOAuthStatus: (status: OAuthStatus) => void
setLoginWithClaudeAi: (value: boolean) => void setLoginWithClaudeAi: (value: boolean) => void
setAppState: ReturnType<typeof useSetAppState>
} }
function OAuthStatusMessage({ function OAuthStatusMessage({
@ -454,10 +464,61 @@ function OAuthStatusMessage({
setOAuthStatus, setOAuthStatus,
setLoginWithClaudeAi, setLoginWithClaudeAi,
onDone, onDone,
setAppState,
}: OAuthStatusMessageProps): React.ReactNode { }: OAuthStatusMessageProps): React.ReactNode {
const setAppState = useSetAppState()
switch (oauthStatus.state) { switch (oauthStatus.state) {
case 'idle': case 'idle': {
const thirdPartyEnabled = isThirdPartyApiEnabled()
const loginOptions: Array<{ label: React.ReactNode; value: string }> = [
{
label: (
<Text>
CoStrict ·{' '}
<Text dimColor>Sign in with CoStrict account</Text>
{'\n'}
</Text>
),
value: 'costrict',
},
]
if (thirdPartyEnabled) {
loginOptions.push(
{
label: (
<Text>
Anthropic Compatible ·{' '}
<Text dimColor>Configure your own API endpoint</Text>
{'\n'}
</Text>
),
value: 'custom_platform',
},
{
label: (
<Text>
OpenAI Compatible ·{' '}
<Text dimColor>
Ollama, DeepSeek, vLLM, One API, etc.
</Text>
{'\n'}
</Text>
),
value: 'openai_chat_api',
},
{
label: (
<Text>
Gemini API ·{' '}
<Text dimColor>Google Gemini native REST/SSE</Text>
{'\n'}
</Text>
),
value: 'gemini_api',
},
)
}
return ( return (
<Box flexDirection="column" gap={1} marginTop={1}> <Box flexDirection="column" gap={1} marginTop={1}>
<Text bold> <Text bold>
@ -470,50 +531,7 @@ function OAuthStatusMessage({
<Box> <Box>
<Select <Select
options={[ options={loginOptions}
{
label: (
<Text>
CoStrict ·{' '}
<Text dimColor>Sign in with CoStrict account</Text>
{'\n'}
</Text>
),
value: 'costrict',
},
{
label: (
<Text>
Anthropic Compatible ·{' '}
<Text dimColor>Configure your own API endpoint</Text>
{'\n'}
</Text>
),
value: 'custom_platform',
},
{
label: (
<Text>
OpenAI Compatible ·{' '}
<Text dimColor>
Ollama, DeepSeek, vLLM, One API, etc.
</Text>
{'\n'}
</Text>
),
value: 'openai_chat_api',
},
{
label: (
<Text>
Gemini API ·{' '}
<Text dimColor>Google Gemini native REST/SSE</Text>
{'\n'}
</Text>
),
value: 'gemini_api',
},
]}
onChange={value => { onChange={value => {
if (value === 'costrict') { if (value === 'costrict') {
void (async () => { void (async () => {
@ -565,12 +583,11 @@ function OAuthStatusMessage({
return return
} }
} catch { } catch {
// 预取失败,直接进入 success // 预取失败,直接进入 success
} }
setOAuthStatus({ state: 'success' }) setOAuthStatus({ state: 'success' })
void onDone() } catch (err: any) {
} catch (err: any) {
setOAuthStatus({ setOAuthStatus({
state: 'error', state: 'error',
message: err.message || String(err), message: err.message || String(err),
@ -617,6 +634,7 @@ function OAuthStatusMessage({
</Box> </Box>
</Box> </Box>
) )
}
case 'custom_platform': case 'custom_platform':
{ {
@ -739,7 +757,6 @@ function OAuthStatusMessage({
// Reset AppState model so logo reflects the new provider default // Reset AppState model so logo reflects the new provider default
setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null })) setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null }))
setOAuthStatus({ state: 'success' }) setOAuthStatus({ state: 'success' })
void onDone()
} }
}, [activeField, inputValue, displayValues, setOAuthStatus, onDone]) }, [activeField, inputValue, displayValues, setOAuthStatus, onDone])
@ -965,7 +982,6 @@ function OAuthStatusMessage({
// Reset AppState model so logo reflects the new provider default // Reset AppState model so logo reflects the new provider default
setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null })) setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null }))
setOAuthStatus({ state: 'success' }) setOAuthStatus({ state: 'success' })
void onDone()
} }
}, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone]) }, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone])
@ -1204,7 +1220,6 @@ function OAuthStatusMessage({
// Reset AppState model so logo reflects the new provider default // Reset AppState model so logo reflects the new provider default
setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null })) setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null }))
setOAuthStatus({ state: 'success' }) setOAuthStatus({ state: 'success' })
void onDone()
} }
}, [activeField, geminiInputValue, geminiDisplayValues, onDone, setOAuthStatus]) }, [activeField, geminiInputValue, geminiDisplayValues, onDone, setOAuthStatus])
@ -1371,7 +1386,6 @@ function OAuthStatusMessage({
updateSettingsForSource('userSettings', { model: value } as any) updateSettingsForSource('userSettings', { model: value } as any)
setAppState(prev => ({ ...prev, mainLoopModel: value, mainLoopModelForSession: null })); setAppState(prev => ({ ...prev, mainLoopModel: value, mainLoopModelForSession: null }));
setOAuthStatus({ state: 'success' }); setOAuthStatus({ state: 'success' });
void onDone();
}} }}
onCancel={() => { onCancel={() => {
const selected = sortedModels[0]?.id ?? ''; const selected = sortedModels[0]?.id ?? '';
@ -1379,7 +1393,6 @@ function OAuthStatusMessage({
updateSettingsForSource('userSettings', { model: selected } as any) updateSettingsForSource('userSettings', { model: selected } as any)
setAppState(prev => ({ ...prev, mainLoopModel: selected, mainLoopModelForSession: null })); setAppState(prev => ({ ...prev, mainLoopModel: selected, mainLoopModelForSession: null }));
setOAuthStatus({ state: 'success' }); setOAuthStatus({ state: 'success' });
void onDone();
}} }}
/> />
</Box> </Box>

View File

@ -57,6 +57,10 @@ import { setShellIfWindows } from '../utils/windowsPaths.js'
import { initSentry } from '../utils/sentry.js' import { initSentry } from '../utils/sentry.js'
import { initUser } from '../utils/user.js' import { initUser } from '../utils/user.js'
import { initLangfuse, shutdownLangfuse } from '../services/langfuse/index.js' import { initLangfuse, shutdownLangfuse } from '../services/langfuse/index.js'
import {
checkApiControlConfig,
disableNonCoStrictProviders,
} from '../services/apiControlConfig/index.js'
import { setThemeConfigCallbacks } from '@anthropic/ink' import { setThemeConfigCallbacks } from '@anthropic/ink'
// initialize1PEventLogging is dynamically imported to defer OpenTelemetry sdk-logs/resources // initialize1PEventLogging is dynamically imported to defer OpenTelemetry sdk-logs/resources
@ -172,6 +176,20 @@ export const init = memoize(async (): Promise<void> => {
logForDebugging('[init] configureGlobalAgents complete') logForDebugging('[init] configureGlobalAgents complete')
profileCheckpoint('init_network_configured') profileCheckpoint('init_network_configured')
// Check remote API control config — if enabled_api is false, disable
// third-party providers (OpenAI, Gemini, Grok, Anthropic) so only
// CoStrict remains available.
try {
const result = await checkApiControlConfig()
if (result.enabled_api === false) {
disableNonCoStrictProviders()
}
} catch (err) {
logForDebugging(
`[init] API control config check failed: ${err instanceof Error ? err.message : String(err)}`,
)
}
// Initialize Sentry for error reporting (no-op if SENTRY_DSN not set) // Initialize Sentry for error reporting (no-op if SENTRY_DSN not set)
initSentry() initSentry()

View File

@ -0,0 +1,106 @@
import { getCoStrictBaseURL } from '../../costrict/provider/auth.js'
import { logForDebugging } from '../../utils/debug.js'
import {
getSettings_DEPRECATED,
updateSettingsForSource,
} from '../../utils/settings/settings.js'
let _apiEnabled: boolean | null = null
export async function checkApiControlConfig(): Promise<{
enabled_api: boolean
}> {
const baseUrl = getCoStrictBaseURL()
const url = `${baseUrl}/costrict-static/cli-api-control/config.json`
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
try {
const response = await fetch(url, { signal: controller.signal })
clearTimeout(timeoutId)
if (response.status !== 200) {
logForDebugging(
`API control config: non-200 status (${response.status}) from ${url}, disabling third-party APIs`,
)
_apiEnabled = false
return { enabled_api: false }
}
let data: unknown
try {
data = await response.json()
} catch {
logForDebugging(
`API control config: JSON parse failed from ${url}, disabling third-party APIs`,
)
_apiEnabled = false
return { enabled_api: false }
}
const enabled =
data !== null &&
typeof data === 'object' &&
'enabled_api' in (data as Record<string, unknown>) &&
(data as Record<string, unknown>).enabled_api === true
_apiEnabled = enabled
logForDebugging(
`API control config: fetched from ${url}, status=${response.status}, enabled_api=${enabled}`,
)
return { enabled_api: enabled }
} catch (err) {
clearTimeout(timeoutId)
const reason = err instanceof Error ? err.message : String(err)
logForDebugging(
`API control config: request failed for ${url}: ${reason}, disabling third-party APIs`,
)
_apiEnabled = false
return { enabled_api: false }
}
}
export function disableNonCoStrictProviders(): void {
// Clear all third-party provider environment variables
delete process.env.CLAUDE_CODE_USE_OPENAI
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_GROK
delete process.env.CLAUDE_CODE_USE_BEDROCK
delete process.env.CLAUDE_CODE_USE_VERTEX
delete process.env.CLAUDE_CODE_USE_FOUNDRY
// Force CoStrict as the only available provider
process.env.CLAUDE_CODE_USE_COSTRICT = '1'
const settings = getSettings_DEPRECATED()
const modelType = settings.modelType as string | undefined
// Clear any non-CoStrict modelType from settings
if (
modelType !== undefined &&
modelType !== 'costrict'
) {
logForDebugging(
`API control config: clearing modelType '${modelType}' from userSettings`,
)
updateSettingsForSource('userSettings', { modelType: undefined })
}
// Clear forceLoginMethod — prevents bypassing the idle login screen
// when third-party APIs are disabled. If forceLoginMethod were left as
// 'claudeai' or 'console', ConsoleOAuthFlow would skip the idle state
// and go directly to Anthropic OAuth, bypassing isThirdPartyApiEnabled().
if (settings.forceLoginMethod) {
logForDebugging(
`API control config: clearing forceLoginMethod '${settings.forceLoginMethod}' from userSettings`,
)
updateSettingsForSource('userSettings', { forceLoginMethod: undefined } as any)
}
}
export function isThirdPartyApiEnabled(): boolean {
return _apiEnabled === true
}