mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
@@ -1,6 +1,6 @@
|
||||
# Provider Integration Flows
|
||||
|
||||
Last Updated: 2026-03-30
|
||||
Last Updated: 2026-05-07
|
||||
|
||||
Detailed provider integration flows including CLIProxyAPI, legacy GLMT compatibility transforms, remote CLIProxy, quota management, and authentication.
|
||||
|
||||
@@ -44,6 +44,11 @@ Generated local CLIProxy configs also keep the management dashboard aligned with
|
||||
| - User enters code at github.com/login/device
|
||||
| - Polls for token completion
|
||||
| |
|
||||
| +---> Browser URL Polling (no callback port)
|
||||
| - Cursor
|
||||
| - Opens provider login URL returned by CLIProxyAPIPlus
|
||||
| - Polls auth state until token is saved
|
||||
| |
|
||||
| v
|
||||
| +------------------+
|
||||
| | OAuth Server | Browser-based auth
|
||||
@@ -81,6 +86,7 @@ Generated local CLIProxy configs also keep the management dashboard aligned with
|
||||
| Antigravity | `agy` | Authorization Code | 9876 | CLIProxyAPI |
|
||||
| Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | 9876 | CLIProxyAPIPlus fork |
|
||||
| GitHub Copilot | `ghcp` | Device Code | none | CLIProxyAPIPlus fork |
|
||||
| Cursor | `cursor` | Browser URL polling | none | CLIProxyAPIPlus fork |
|
||||
|
||||
### Codex Duplicate-Email Account Identity
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
buildProviderAliasMap,
|
||||
CLIPROXY_PROVIDER_IDS,
|
||||
getDeviceCodeVerificationProviders,
|
||||
getOAuthCallbackPort,
|
||||
getOAuthFlowType,
|
||||
PROVIDER_CAPABILITIES,
|
||||
@@ -76,6 +77,17 @@ describe('provider-capabilities', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('separates browser URL auth providers from verification-code device flows', () => {
|
||||
expect(getDeviceCodeVerificationProviders()).toEqual([
|
||||
'qwen',
|
||||
'kiro',
|
||||
'ghcp',
|
||||
'kimi',
|
||||
'codebuddy',
|
||||
'kilo',
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps external provider aliases to canonical IDs', () => {
|
||||
expect(mapExternalProviderName('gemini-cli')).toBe('gemini');
|
||||
expect(mapExternalProviderName('antigravity')).toBe('agy');
|
||||
|
||||
@@ -9,9 +9,9 @@ import { CLIPROXY_DEFAULT_PORT as BACKEND_CLIPROXY_DEFAULT_PORT } from '../port-
|
||||
import { DEFAULT_CURSOR_PORT as BACKEND_CURSOR_DEFAULT_PORT } from '../../../cursor/cursor-models';
|
||||
import {
|
||||
CLIPROXY_PROVIDER_IDS as BACKEND_CLIPROXY_PROVIDER_IDS,
|
||||
getDeviceCodeVerificationProviders,
|
||||
getProviderDescription as getBackendProviderDescription,
|
||||
getProviderDisplayName as getBackendProviderDisplayName,
|
||||
getProvidersByOAuthFlow,
|
||||
} from '../../provider-capabilities';
|
||||
import {
|
||||
CLIPROXY_DEFAULT_PORT as UI_CLIPROXY_DEFAULT_PORT,
|
||||
@@ -20,9 +20,9 @@ import {
|
||||
import {
|
||||
CLIPROXY_PROVIDERS as UI_CLIPROXY_PROVIDERS,
|
||||
CORE_CLIPROXY_PROVIDERS as UI_CORE_CLIPROXY_PROVIDERS,
|
||||
DEVICE_CODE_PROVIDERS as UI_DEVICE_CODE_PROVIDERS,
|
||||
PLUS_EXTRA_CLIPROXY_PROVIDERS as UI_PLUS_EXTRA_CLIPROXY_PROVIDERS,
|
||||
PROVIDER_METADATA as UI_PROVIDER_METADATA,
|
||||
VERIFICATION_CODE_AUTH_PROVIDERS as UI_VERIFICATION_CODE_AUTH_PROVIDERS,
|
||||
} from '../../../../ui/src/lib/provider-config';
|
||||
import { PLUS_ONLY_PROVIDERS as BACKEND_PLUS_ONLY_PROVIDERS } from '../../types/index';
|
||||
|
||||
@@ -43,9 +43,9 @@ describe('Default Port Sync', () => {
|
||||
expect(sorted(UI_CLIPROXY_PROVIDERS)).toEqual(sorted(BACKEND_CLIPROXY_PROVIDER_IDS));
|
||||
});
|
||||
|
||||
test('Device code providers are synced between backend and UI', () => {
|
||||
expect(sorted(UI_DEVICE_CODE_PROVIDERS)).toEqual(
|
||||
sorted(getProvidersByOAuthFlow('device_code'))
|
||||
test('verification-code auth providers are synced between backend and UI', () => {
|
||||
expect(sorted(UI_VERIFICATION_CODE_AUTH_PROVIDERS)).toEqual(
|
||||
sorted(getDeviceCodeVerificationProviders())
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -188,6 +188,12 @@ export const CLIPROXY_PROVIDER_IDS = Object.freeze(
|
||||
Object.keys(PROVIDER_CAPABILITIES) as CLIProxyProvider[]
|
||||
);
|
||||
|
||||
export const BROWSER_URL_AUTH_PROVIDER_IDS = Object.freeze([
|
||||
'cursor',
|
||||
] as const satisfies readonly CLIProxyProvider[]);
|
||||
|
||||
const BROWSER_URL_AUTH_PROVIDER_SET = new Set<CLIProxyProvider>(BROWSER_URL_AUTH_PROVIDER_IDS);
|
||||
|
||||
/** Providers currently supported by quota status fetchers. */
|
||||
export const QUOTA_SUPPORTED_PROVIDER_IDS = Object.freeze([
|
||||
'agy',
|
||||
@@ -278,6 +284,16 @@ export function getProvidersByOAuthFlow(flowType: OAuthFlowType): CLIProxyProvid
|
||||
);
|
||||
}
|
||||
|
||||
export function isBrowserUrlAuthProvider(provider: CLIProxyProvider): boolean {
|
||||
return BROWSER_URL_AUTH_PROVIDER_SET.has(provider);
|
||||
}
|
||||
|
||||
export function getDeviceCodeVerificationProviders(): CLIProxyProvider[] {
|
||||
return getProvidersByOAuthFlow('device_code').filter(
|
||||
(provider) => !isBrowserUrlAuthProvider(provider)
|
||||
);
|
||||
}
|
||||
|
||||
export function getOAuthFlowType(provider: CLIProxyProvider): OAuthFlowType {
|
||||
return PROVIDER_CAPABILITIES[provider].oauthFlow;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,11 @@ import {
|
||||
normalizeKiroAuthMethod,
|
||||
toKiroManagementMethod,
|
||||
} from '../../cliproxy/auth/auth-types';
|
||||
import { getOAuthFlowType, mapExternalProviderName } from '../../cliproxy/provider-capabilities';
|
||||
import {
|
||||
getOAuthFlowType,
|
||||
isBrowserUrlAuthProvider,
|
||||
mapExternalProviderName,
|
||||
} from '../../cliproxy/provider-capabilities';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
||||
import {
|
||||
@@ -286,6 +290,10 @@ export function getStartUrlUnsupportedReason(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isBrowserUrlAuthProvider(provider)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (getOAuthFlowType(provider) === 'device_code') {
|
||||
return `Provider '${provider}' uses Device Code flow. Use /api/cliproxy/auth/${provider}/start instead.`;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
getDeviceCodeProviderInstruction,
|
||||
getProviderDisplayName,
|
||||
isDeviceCodeProvider,
|
||||
} from '../../../ui/src/lib/provider-config';
|
||||
|
||||
describe('provider-config fallbacks', () => {
|
||||
@@ -15,4 +16,10 @@ describe('provider-config fallbacks', () => {
|
||||
'Complete the authorization in your browser.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not classify Cursor browser URL auth as a verification-code device flow', () => {
|
||||
expect(isDeviceCodeProvider('cursor')).toBe(false);
|
||||
expect(isDeviceCodeProvider('ghcp')).toBe(true);
|
||||
expect(isDeviceCodeProvider('qwen')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
getCachedQuota,
|
||||
setCachedQuota,
|
||||
} from '../../../src/cliproxy/quota/quota-response-cache';
|
||||
import { restoreFetch, mockFetch } from '../../mocks';
|
||||
import { restoreFetch, mockFetch, getCapturedFetchRequests } from '../../mocks';
|
||||
|
||||
describe('cliproxy-auth-routes manual callback nickname persistence', () => {
|
||||
let server: Server;
|
||||
@@ -179,6 +179,34 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => {
|
||||
expect(registry.providers.kiro.accounts['github-ABC123']?.nickname).toBe('work');
|
||||
});
|
||||
|
||||
it('starts Cursor auth through the browser URL management endpoint', async () => {
|
||||
mockFetch([
|
||||
{
|
||||
url: /\/v0\/management\/cursor-auth-url\?is_webui=true$/,
|
||||
response: {
|
||||
url: 'https://cursor.com/loginDeepControl?state=cursor-state',
|
||||
state: 'cursor-state',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const startResponse = await postJson('/api/cliproxy/auth/cursor/start-url', {
|
||||
nickname: 'work',
|
||||
});
|
||||
|
||||
expect(startResponse.status).toBe(200);
|
||||
expect(startResponse.body).toEqual({
|
||||
success: true,
|
||||
authUrl: 'https://cursor.com/loginDeepControl?state=cursor-state',
|
||||
state: 'cursor-state',
|
||||
method: null,
|
||||
});
|
||||
|
||||
const requests = getCapturedFetchRequests();
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]?.url).toContain('/v0/management/cursor-auth-url?is_webui=true');
|
||||
});
|
||||
|
||||
it('returns wait after callback submission when the local token is not yet available', async () => {
|
||||
mockFetch([
|
||||
{
|
||||
|
||||
@@ -13,15 +13,14 @@ describe('cliproxy-auth-routes start-url guard', () => {
|
||||
);
|
||||
expect(getStartUrlUnsupportedReason('ghcp')).toContain("Provider 'ghcp' uses Device Code flow");
|
||||
expect(getStartUrlUnsupportedReason('qwen')).toContain("Provider 'qwen' uses Device Code flow");
|
||||
expect(getStartUrlUnsupportedReason('cursor')).toContain(
|
||||
"Provider 'cursor' uses Device Code flow"
|
||||
);
|
||||
expect(getStartUrlUnsupportedReason('codebuddy')).toContain(
|
||||
"Provider 'codebuddy' uses Device Code flow"
|
||||
);
|
||||
expect(getStartUrlUnsupportedReason('kilo')).toContain(
|
||||
"Provider 'kilo' uses Device Code flow"
|
||||
);
|
||||
expect(getStartUrlUnsupportedReason('kilo')).toContain("Provider 'kilo' uses Device Code flow");
|
||||
});
|
||||
|
||||
it('allows Cursor browser URL auth on start-url', () => {
|
||||
expect(getStartUrlUnsupportedReason('cursor')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows Kiro social methods on start-url', () => {
|
||||
@@ -138,6 +137,8 @@ describe('cliproxy-auth-routes nickname validation', () => {
|
||||
];
|
||||
|
||||
expect(getStartAuthNicknameError('kiro', 'work', existingAccounts, 'github-ABC123')).toBeNull();
|
||||
expect(getStartAuthNicknameError('kiro', 'github-ABC123', existingAccounts, 'github-ABC123')).toBeNull();
|
||||
expect(
|
||||
getStartAuthNicknameError('kiro', 'github-ABC123', existingAccounts, 'github-ABC123')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -293,8 +293,8 @@ export function AddAccountDialog({
|
||||
|
||||
/**
|
||||
* Start auth flow using provider capabilities.
|
||||
* - Device code providers use /start and rely on WebSocket events for code display.
|
||||
* - Authorization code providers use /start-url and polling.
|
||||
* - Verification-code providers use /start and WebSocket events for code display.
|
||||
* - Browser URL providers use /start-url and polling.
|
||||
*/
|
||||
const handleAuthenticate = () => {
|
||||
if (isPowerUserModePending) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import {
|
||||
CLIPROXY_PROVIDER_IDS,
|
||||
PROVIDER_CAPABILITIES,
|
||||
getProvidersByOAuthFlow,
|
||||
getDeviceCodeVerificationProviders,
|
||||
} from '../../../src/cliproxy/provider-capabilities';
|
||||
import type { AiProviderFamilyId, AiProviderModelAlias } from '../../../src/cliproxy/ai-providers';
|
||||
import { PLUS_ONLY_PROVIDERS } from '../../../src/cliproxy/types';
|
||||
@@ -346,13 +346,14 @@ export function getProviderDescription(provider: unknown): string {
|
||||
return PROVIDER_METADATA[normalized].description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers that use Device Code OAuth flow instead of Authorization Code flow.
|
||||
*/
|
||||
export const DEVICE_CODE_PROVIDERS: CLIProxyProvider[] = [
|
||||
...getProvidersByOAuthFlow('device_code'),
|
||||
/** Providers whose add-account UX shows a verification code and uses `/start`. */
|
||||
export const VERIFICATION_CODE_AUTH_PROVIDERS: CLIProxyProvider[] = [
|
||||
...getDeviceCodeVerificationProviders(),
|
||||
];
|
||||
|
||||
/** Backward-compatible alias for verification-code auth providers. */
|
||||
export const DEVICE_CODE_PROVIDERS = VERIFICATION_CODE_AUTH_PROVIDERS;
|
||||
|
||||
const DEVICE_CODE_PROVIDER_DISPLAY_NAMES: Readonly<Partial<Record<CLIProxyProvider, string>>> =
|
||||
Object.freeze({
|
||||
ghcp: 'GitHub Copilot',
|
||||
@@ -368,10 +369,10 @@ const DEVICE_CODE_PROVIDER_INSTRUCTIONS: Readonly<Partial<Record<CLIProxyProvide
|
||||
kimi: 'Sign in with your Kimi account and finish the device authorization.',
|
||||
});
|
||||
|
||||
/** Check if provider uses Device Code flow */
|
||||
/** Check if the add-account UI should use the verification-code auth dialog. */
|
||||
export function isDeviceCodeProvider(provider: unknown): boolean {
|
||||
const normalized = normalizeProviderInput(provider);
|
||||
return isValidProvider(normalized) && DEVICE_CODE_PROVIDERS.includes(normalized);
|
||||
return isValidProvider(normalized) && VERIFICATION_CODE_AUTH_PROVIDERS.includes(normalized);
|
||||
}
|
||||
|
||||
/** Provider display name tuned for device-code UX copy. */
|
||||
|
||||
@@ -45,6 +45,47 @@ describe('useCliproxyAuthFlow', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('routes Cursor through start-url browser polling by default', async () => {
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
||||
if (url.includes('/cursor/start-url')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
success: true,
|
||||
authUrl: 'https://cursor.com/loginDeepControl?state=cursor-state',
|
||||
state: 'cursor-state',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/status?state=cursor-state')) {
|
||||
return Promise.resolve(createJsonResponse({ status: 'wait' }));
|
||||
}
|
||||
|
||||
return Promise.resolve(createJsonResponse({ success: true, account: { id: 'cursor-test' } }));
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startAuth('cursor');
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/cliproxy/auth/cursor/start-url',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
expect(result.current.isDeviceCodeFlow).toBe(false);
|
||||
expect(result.current.authUrl).toContain('cursor.com/loginDeepControl');
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://cursor.com/loginDeepControl?state=cursor-state',
|
||||
'_blank'
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores stale poll completions from a superseded auth attempt', async () => {
|
||||
const firstPoll = createDeferred<Response>();
|
||||
let startCount = 0;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getRequestedUpstreamModelRuleErrors,
|
||||
getRequestedModelId,
|
||||
groupProvidersBySection,
|
||||
isDeviceCodeProvider,
|
||||
isPlusExtraProvider,
|
||||
parseRequestedUpstreamModelRules,
|
||||
PLUS_EXTRA_CLIPROXY_PROVIDERS,
|
||||
@@ -90,6 +91,12 @@ describe('provider presentation metadata', () => {
|
||||
expect(grouped[1]?.items.map((entry) => entry.provider)).toEqual(['cursor']);
|
||||
});
|
||||
|
||||
it('keeps Cursor out of verification-code device flow routing', () => {
|
||||
expect(isDeviceCodeProvider('cursor')).toBe(false);
|
||||
expect(isDeviceCodeProvider('ghcp')).toBe(true);
|
||||
expect(isDeviceCodeProvider('qwen')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects plus-extra providers inside composite variants', () => {
|
||||
expect(
|
||||
variantUsesPlusExtraProvider({
|
||||
|
||||
Reference in New Issue
Block a user