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

View File

@ -19,6 +19,7 @@ import { Spinner } from './Spinner.js'
import TextInput from './TextInput.js'
import { useSetAppState } from '../state/AppState.js'
import { fi } from 'zod/v4/locales'
import { isThirdPartyApiEnabled } from '../services/apiControlConfig/index.js'
type Props = {
onDone(): void
@ -100,6 +101,13 @@ export function ConsoleOAuthFlow({
return { state: 'ready_to_start' }
}
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: 'idle' }
@ -416,6 +424,7 @@ export function ConsoleOAuthFlow({
setOAuthStatus={setOAuthStatus}
setLoginWithClaudeAi={setLoginWithClaudeAi}
onDone={onDone}
setAppState={setAppState}
/>
</Box>
</Box>
@ -437,6 +446,7 @@ type OAuthStatusMessageProps = {
handleSubmitCode: (value: string, url: string) => void
setOAuthStatus: (status: OAuthStatus) => void
setLoginWithClaudeAi: (value: boolean) => void
setAppState: ReturnType<typeof useSetAppState>
}
function OAuthStatusMessage({
@ -454,10 +464,61 @@ function OAuthStatusMessage({
setOAuthStatus,
setLoginWithClaudeAi,
onDone,
setAppState,
}: OAuthStatusMessageProps): React.ReactNode {
const setAppState = useSetAppState()
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 (
<Box flexDirection="column" gap={1} marginTop={1}>
<Text bold>
@ -470,50 +531,7 @@ function OAuthStatusMessage({
<Box>
<Select
options={[
{
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',
},
]}
options={loginOptions}
onChange={value => {
if (value === 'costrict') {
void (async () => {
@ -565,12 +583,11 @@ function OAuthStatusMessage({
return
}
} catch {
// 预取失败,直接进入 success
}
setOAuthStatus({ state: 'success' })
void onDone()
} catch (err: any) {
// 预取失败,直接进入 success
}
setOAuthStatus({ state: 'success' })
} catch (err: any) {
setOAuthStatus({
state: 'error',
message: err.message || String(err),
@ -617,6 +634,7 @@ function OAuthStatusMessage({
</Box>
</Box>
)
}
case 'custom_platform':
{
@ -739,7 +757,6 @@ function OAuthStatusMessage({
// Reset AppState model so logo reflects the new provider default
setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null }))
setOAuthStatus({ state: 'success' })
void onDone()
}
}, [activeField, inputValue, displayValues, setOAuthStatus, onDone])
@ -965,7 +982,6 @@ function OAuthStatusMessage({
// Reset AppState model so logo reflects the new provider default
setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null }))
setOAuthStatus({ state: 'success' })
void onDone()
}
}, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone])
@ -1204,7 +1220,6 @@ function OAuthStatusMessage({
// Reset AppState model so logo reflects the new provider default
setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null }))
setOAuthStatus({ state: 'success' })
void onDone()
}
}, [activeField, geminiInputValue, geminiDisplayValues, onDone, setOAuthStatus])
@ -1371,7 +1386,6 @@ function OAuthStatusMessage({
updateSettingsForSource('userSettings', { model: value } as any)
setAppState(prev => ({ ...prev, mainLoopModel: value, mainLoopModelForSession: null }));
setOAuthStatus({ state: 'success' });
void onDone();
}}
onCancel={() => {
const selected = sortedModels[0]?.id ?? '';
@ -1379,7 +1393,6 @@ function OAuthStatusMessage({
updateSettingsForSource('userSettings', { model: selected } as any)
setAppState(prev => ({ ...prev, mainLoopModel: selected, mainLoopModelForSession: null }));
setOAuthStatus({ state: 'success' });
void onDone();
}}
/>
</Box>

View File

@ -57,6 +57,10 @@ import { setShellIfWindows } from '../utils/windowsPaths.js'
import { initSentry } from '../utils/sentry.js'
import { initUser } from '../utils/user.js'
import { initLangfuse, shutdownLangfuse } from '../services/langfuse/index.js'
import {
checkApiControlConfig,
disableNonCoStrictProviders,
} from '../services/apiControlConfig/index.js'
import { setThemeConfigCallbacks } from '@anthropic/ink'
// 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')
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)
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
}