mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 14:14:47 +00:00
fix(cliproxy): guard unsupported Qwen account auth
This commit is contained in:
1 parent
5b6c3e87e9
commit
b6ef4e5782
11 files changed
+177
-9
No files matched your search
@@ -61,11 +61,14 @@ CCS gives you one stable command surface while letting you switch between:
|
|||||||
|
|
||||||
- multiple runtimes such as Claude Code, Factory Droid, and Codex CLI
|
- multiple runtimes such as Claude Code, Factory Droid, and Codex CLI
|
||||||
- multiple Claude subscriptions and isolated account contexts
|
- multiple Claude subscriptions and isolated account contexts
|
||||||
- OAuth providers like Codex, Kiro, Claude, Qwen, Kimi, and more, with legacy
|
- OAuth providers like Codex, Kiro, Claude, Kimi, and more, with legacy
|
||||||
Copilot compatibility for existing setups
|
Copilot compatibility for existing setups
|
||||||
- API and local-model profiles like GLM, Kimi, OpenRouter, Ollama, llama.cpp,
|
- API and local-model profiles like GLM, Kimi, OpenRouter, Ollama, llama.cpp,
|
||||||
Novita, Fireworks AI, and Alibaba Coding Plan
|
Novita, Fireworks AI, and Alibaba Coding Plan
|
||||||
|
|
||||||
|
Qwen Code account linking is not available in the bundled CLIProxy runtime yet;
|
||||||
|
use an API-key Qwen profile such as Alibaba Coding Plan for Qwen models.
|
||||||
|
|
||||||
The goal is simple: stop rewriting config files, stop breaking active sessions,
|
The goal is simple: stop rewriting config files, stop breaking active sessions,
|
||||||
and move between providers in seconds.
|
and move between providers in seconds.
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getDeviceCodeVerificationProviders,
|
getDeviceCodeVerificationProviders,
|
||||||
getOAuthCallbackPort,
|
getOAuthCallbackPort,
|
||||||
getOAuthFlowType,
|
getOAuthFlowType,
|
||||||
|
getUnsupportedAuthStartReason,
|
||||||
PROVIDER_CAPABILITIES,
|
PROVIDER_CAPABILITIES,
|
||||||
getProviderDisplayName,
|
getProviderDisplayName,
|
||||||
getProvidersByOAuthFlow,
|
getProvidersByOAuthFlow,
|
||||||
@@ -134,10 +135,20 @@ describe('provider-capabilities', () => {
|
|||||||
expect(getOAuthCallbackPort('gitlab')).toBe(17171);
|
expect(getOAuthCallbackPort('gitlab')).toBe(17171);
|
||||||
expect(getOAuthCallbackPort('gemini')).toBe(8085);
|
expect(getOAuthCallbackPort('gemini')).toBe(8085);
|
||||||
expect(PROVIDER_CAPABILITIES.gemini.refreshOwnership).toBe('cliproxy');
|
expect(PROVIDER_CAPABILITIES.gemini.refreshOwnership).toBe('cliproxy');
|
||||||
|
expect(PROVIDER_CAPABILITIES.qwen.refreshOwnership).toBe('unsupported');
|
||||||
expect(getProviderDisplayName('agy')).toBe('Antigravity');
|
expect(getProviderDisplayName('agy')).toBe('Antigravity');
|
||||||
expect(getProviderDisplayName('kilo')).toBe('Kilo AI');
|
expect(getProviderDisplayName('kilo')).toBe('Kilo AI');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes auth start support separately from OAuth flow type', () => {
|
||||||
|
expect(getOAuthFlowType('qwen')).toBe('device_code');
|
||||||
|
expect(getUnsupportedAuthStartReason('qwen')).toContain(
|
||||||
|
'Qwen account linking is not supported'
|
||||||
|
);
|
||||||
|
expect(getUnsupportedAuthStartReason('kiro')).toBeNull();
|
||||||
|
expect(getUnsupportedAuthStartReason('qoder')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('throws when provider aliases collide across providers', () => {
|
it('throws when provider aliases collide across providers', () => {
|
||||||
const capabilitiesWithCollision = {
|
const capabilitiesWithCollision = {
|
||||||
...PROVIDER_CAPABILITIES,
|
...PROVIDER_CAPABILITIES,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { afterEach, describe, expect, it, spyOn } from 'bun:test';
|
||||||
|
import { triggerOAuth } from '../oauth-handler';
|
||||||
|
|
||||||
|
describe('triggerOAuth unsupported providers', () => {
|
||||||
|
const previousDisableBanWarnings = process.env.CCS_DISABLE_BAN_WARNINGS;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (previousDisableBanWarnings === undefined) {
|
||||||
|
delete process.env.CCS_DISABLE_BAN_WARNINGS;
|
||||||
|
} else {
|
||||||
|
process.env.CCS_DISABLE_BAN_WARNINGS = previousDisableBanWarnings;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails Qwen account linking before preparing CLIProxy auth args', async () => {
|
||||||
|
process.env.CCS_DISABLE_BAN_WARNINGS = '1';
|
||||||
|
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const account = await triggerOAuth('qwen');
|
||||||
|
|
||||||
|
expect(account).toBeNull();
|
||||||
|
expect(logSpy.mock.calls.some(([message]) => String(message).includes('--qwen-login'))).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
logSpy.mock.calls.some(([message]) =>
|
||||||
|
String(message).includes('Qwen account linking is not supported')
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
} finally {
|
||||||
|
logSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -79,6 +79,7 @@ import {
|
|||||||
import { maybeOfferPoolRouting } from '../routing/pool-opt-in-prompt';
|
import { maybeOfferPoolRouting } from '../routing/pool-opt-in-prompt';
|
||||||
import { checkCrossLaneEmailOverlap } from '../accounts/account-safety-cross-lane';
|
import { checkCrossLaneEmailOverlap } from '../accounts/account-safety-cross-lane';
|
||||||
import { ensureCliAntigravityResponsibility } from '../auth/antigravity-responsibility';
|
import { ensureCliAntigravityResponsibility } from '../auth/antigravity-responsibility';
|
||||||
|
import { getUnsupportedAuthStartReason } from '../provider-capabilities';
|
||||||
import { InteractivePrompt } from '../../utils/prompt';
|
import { InteractivePrompt } from '../../utils/prompt';
|
||||||
import { getCcsDir } from '../../utils/config-manager';
|
import { getCcsDir } from '../../utils/config-manager';
|
||||||
import { generateSessionId } from './project-selection-handler';
|
import { generateSessionId } from './project-selection-handler';
|
||||||
@@ -616,6 +617,11 @@ function buildOAuthArgs(
|
|||||||
kiroIDCFlow?: OAuthOptions['kiroIDCFlow'];
|
kiroIDCFlow?: OAuthOptions['kiroIDCFlow'];
|
||||||
} = {}
|
} = {}
|
||||||
): string[] {
|
): string[] {
|
||||||
|
const unsupportedReason = getUnsupportedAuthStartReason(provider);
|
||||||
|
if (unsupportedReason) {
|
||||||
|
throw new AuthError(unsupportedReason, provider);
|
||||||
|
}
|
||||||
|
|
||||||
const args = ['--config', configPath];
|
const args = ['--config', configPath];
|
||||||
|
|
||||||
if (provider === 'kiro') {
|
if (provider === 'kiro') {
|
||||||
@@ -1091,6 +1097,12 @@ export async function triggerOAuth(
|
|||||||
options: OAuthOptions = {}
|
options: OAuthOptions = {}
|
||||||
): Promise<AccountInfo | null> {
|
): Promise<AccountInfo | null> {
|
||||||
const oauthConfig = getOAuthConfig(provider);
|
const oauthConfig = getOAuthConfig(provider);
|
||||||
|
const unsupportedReason = getUnsupportedAuthStartReason(provider);
|
||||||
|
if (unsupportedReason) {
|
||||||
|
console.log(fail(unsupportedReason));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
warnOAuthBanRisk(provider);
|
warnOAuthBanRisk(provider);
|
||||||
const oauthStartedAt = Date.now();
|
const oauthStartedAt = Date.now();
|
||||||
logger.stage('auth', 'cliproxy.oauth.start', 'Triggering OAuth flow', {
|
logger.stage('auth', 'cliproxy.oauth.start', 'Triggering OAuth flow', {
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
* Exports refresh functions for each OAuth provider.
|
* Exports refresh functions for each OAuth provider.
|
||||||
*
|
*
|
||||||
* Refresh responsibility:
|
* Refresh responsibility:
|
||||||
* - CLIProxy-delegated: gemini, codex, agy, kiro, ghcp, qwen, iflow, kimi
|
* - CLIProxy-delegated: gemini, codex, agy, kiro, ghcp, iflow, kimi
|
||||||
* (CLIProxyAPIPlus handles refresh automatically in background)
|
* (CLIProxyAPIPlus handles refresh automatically in background)
|
||||||
|
* - Unsupported account linking: qwen
|
||||||
* - Not implemented: claude
|
* - Not implemented: claude
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -497,9 +497,10 @@ export function displayAuthStatus(): void {
|
|||||||
*
|
*
|
||||||
* Refresh responsibility:
|
* Refresh responsibility:
|
||||||
* - gemini: CCS refreshes directly via Google OAuth
|
* - gemini: CCS refreshes directly via Google OAuth
|
||||||
* - codex, agy, kiro, ghcp, qwen, iflow: CLIProxyAPIPlus handles refresh
|
* - codex, agy, kiro, ghcp, iflow: CLIProxyAPIPlus handles refresh
|
||||||
* automatically in background (e.g. kiro refreshes every 1 min).
|
* automatically in background (e.g. kiro refreshes every 1 min).
|
||||||
* CCS only checks if token file exists (authentication state).
|
* CCS only checks if token file exists (authentication state).
|
||||||
|
* - qwen: account linking is unsupported by the bundled CLIProxy runtime
|
||||||
* - claude: not yet implemented
|
* - claude: not yet implemented
|
||||||
*
|
*
|
||||||
* @param provider The CLIProxy provider
|
* @param provider The CLIProxy provider
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { CLIProxyProvider } from './types';
|
import type { CLIProxyProvider } from './types';
|
||||||
|
import { ConfigError } from '../errors/error-types';
|
||||||
|
|
||||||
export type OAuthFlowType = 'authorization_code' | 'device_code';
|
export type OAuthFlowType = 'authorization_code' | 'device_code';
|
||||||
export type TokenRefreshOwnership = 'ccs' | 'cliproxy' | 'unsupported';
|
export type TokenRefreshOwnership = 'ccs' | 'cliproxy' | 'unsupported';
|
||||||
|
export type AuthStartSupport = 'cliproxy-cli' | 'unsupported';
|
||||||
|
|
||||||
export interface ProviderCapabilities {
|
export interface ProviderCapabilities {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -14,6 +16,10 @@ export interface ProviderCapabilities {
|
|||||||
authUrlProviderName: string;
|
authUrlProviderName: string;
|
||||||
/** Who owns token refresh logic for this provider. */
|
/** Who owns token refresh logic for this provider. */
|
||||||
refreshOwnership: TokenRefreshOwnership;
|
refreshOwnership: TokenRefreshOwnership;
|
||||||
|
/** Whether CCS can start account linking through the bundled CLIProxy binary. */
|
||||||
|
authStartSupport: AuthStartSupport;
|
||||||
|
/** User-facing reason when account linking cannot be started. */
|
||||||
|
authStartUnsupportedReason?: string;
|
||||||
/** Filename prefixes used to identify auth tokens for this provider. */
|
/** Filename prefixes used to identify auth tokens for this provider. */
|
||||||
authFilePrefixes: readonly string[];
|
authFilePrefixes: readonly string[];
|
||||||
/** Token JSON "type" values accepted for this provider. */
|
/** Token JSON "type" values accepted for this provider. */
|
||||||
@@ -34,6 +40,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
|
|||||||
callbackProviderName: 'gemini',
|
callbackProviderName: 'gemini',
|
||||||
authUrlProviderName: 'gemini-cli',
|
authUrlProviderName: 'gemini-cli',
|
||||||
refreshOwnership: 'cliproxy',
|
refreshOwnership: 'cliproxy',
|
||||||
|
authStartSupport: 'cliproxy-cli',
|
||||||
authFilePrefixes: ['gemini-', 'google-'],
|
authFilePrefixes: ['gemini-', 'google-'],
|
||||||
tokenTypeValues: ['gemini'],
|
tokenTypeValues: ['gemini'],
|
||||||
aliases: ['gemini-cli'],
|
aliases: ['gemini-cli'],
|
||||||
@@ -46,6 +53,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
|
|||||||
callbackProviderName: 'codex',
|
callbackProviderName: 'codex',
|
||||||
authUrlProviderName: 'codex',
|
authUrlProviderName: 'codex',
|
||||||
refreshOwnership: 'cliproxy',
|
refreshOwnership: 'cliproxy',
|
||||||
|
authStartSupport: 'cliproxy-cli',
|
||||||
authFilePrefixes: ['codex-', 'openai-'],
|
authFilePrefixes: ['codex-', 'openai-'],
|
||||||
tokenTypeValues: ['codex'],
|
tokenTypeValues: ['codex'],
|
||||||
aliases: [],
|
aliases: [],
|
||||||
@@ -58,6 +66,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
|
|||||||
callbackProviderName: 'antigravity',
|
callbackProviderName: 'antigravity',
|
||||||
authUrlProviderName: 'antigravity',
|
authUrlProviderName: 'antigravity',
|
||||||
refreshOwnership: 'cliproxy',
|
refreshOwnership: 'cliproxy',
|
||||||
|
authStartSupport: 'cliproxy-cli',
|
||||||
authFilePrefixes: ['antigravity-', 'agy-'],
|
authFilePrefixes: ['antigravity-', 'agy-'],
|
||||||
tokenTypeValues: ['antigravity'],
|
tokenTypeValues: ['antigravity'],
|
||||||
aliases: ['antigravity'],
|
aliases: ['antigravity'],
|
||||||
@@ -69,7 +78,10 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
|
|||||||
callbackPort: null,
|
callbackPort: null,
|
||||||
callbackProviderName: 'qwen',
|
callbackProviderName: 'qwen',
|
||||||
authUrlProviderName: 'qwen',
|
authUrlProviderName: 'qwen',
|
||||||
refreshOwnership: 'cliproxy',
|
refreshOwnership: 'unsupported',
|
||||||
|
authStartSupport: 'unsupported',
|
||||||
|
authStartUnsupportedReason:
|
||||||
|
'Alibaba Qwen account linking is not supported by the bundled CLIProxy runtime. Use an API-key Qwen profile; CLIProxyAPI does not expose Qwen OAuth yet.',
|
||||||
authFilePrefixes: ['qwen-'],
|
authFilePrefixes: ['qwen-'],
|
||||||
tokenTypeValues: ['qwen'],
|
tokenTypeValues: ['qwen'],
|
||||||
aliases: [],
|
aliases: [],
|
||||||
@@ -82,6 +94,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
|
|||||||
callbackProviderName: 'iflow',
|
callbackProviderName: 'iflow',
|
||||||
authUrlProviderName: 'iflow',
|
authUrlProviderName: 'iflow',
|
||||||
refreshOwnership: 'cliproxy',
|
refreshOwnership: 'cliproxy',
|
||||||
|
authStartSupport: 'cliproxy-cli',
|
||||||
authFilePrefixes: ['iflow-'],
|
authFilePrefixes: ['iflow-'],
|
||||||
tokenTypeValues: ['iflow'],
|
tokenTypeValues: ['iflow'],
|
||||||
aliases: [],
|
aliases: [],
|
||||||
@@ -94,6 +107,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
|
|||||||
callbackProviderName: 'kiro',
|
callbackProviderName: 'kiro',
|
||||||
authUrlProviderName: 'kiro',
|
authUrlProviderName: 'kiro',
|
||||||
refreshOwnership: 'cliproxy',
|
refreshOwnership: 'cliproxy',
|
||||||
|
authStartSupport: 'cliproxy-cli',
|
||||||
authFilePrefixes: ['kiro-', 'aws-', 'codewhisperer-'],
|
authFilePrefixes: ['kiro-', 'aws-', 'codewhisperer-'],
|
||||||
tokenTypeValues: ['kiro', 'codewhisperer'],
|
tokenTypeValues: ['kiro', 'codewhisperer'],
|
||||||
aliases: ['codewhisperer'],
|
aliases: ['codewhisperer'],
|
||||||
@@ -106,6 +120,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
|
|||||||
callbackProviderName: 'copilot',
|
callbackProviderName: 'copilot',
|
||||||
authUrlProviderName: 'github',
|
authUrlProviderName: 'github',
|
||||||
refreshOwnership: 'cliproxy',
|
refreshOwnership: 'cliproxy',
|
||||||
|
authStartSupport: 'cliproxy-cli',
|
||||||
authFilePrefixes: ['github-copilot-', 'copilot-', 'gh-'],
|
authFilePrefixes: ['github-copilot-', 'copilot-', 'gh-'],
|
||||||
tokenTypeValues: ['github-copilot', 'copilot'],
|
tokenTypeValues: ['github-copilot', 'copilot'],
|
||||||
aliases: ['github-copilot', 'copilot'],
|
aliases: ['github-copilot', 'copilot'],
|
||||||
@@ -118,6 +133,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
|
|||||||
callbackProviderName: 'anthropic',
|
callbackProviderName: 'anthropic',
|
||||||
authUrlProviderName: 'anthropic',
|
authUrlProviderName: 'anthropic',
|
||||||
refreshOwnership: 'unsupported',
|
refreshOwnership: 'unsupported',
|
||||||
|
authStartSupport: 'cliproxy-cli',
|
||||||
authFilePrefixes: ['claude-', 'anthropic-'],
|
authFilePrefixes: ['claude-', 'anthropic-'],
|
||||||
tokenTypeValues: ['claude', 'anthropic'],
|
tokenTypeValues: ['claude', 'anthropic'],
|
||||||
aliases: ['anthropic'],
|
aliases: ['anthropic'],
|
||||||
@@ -130,6 +146,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
|
|||||||
callbackProviderName: 'kimi',
|
callbackProviderName: 'kimi',
|
||||||
authUrlProviderName: 'kimi',
|
authUrlProviderName: 'kimi',
|
||||||
refreshOwnership: 'cliproxy',
|
refreshOwnership: 'cliproxy',
|
||||||
|
authStartSupport: 'cliproxy-cli',
|
||||||
authFilePrefixes: ['kimi-'],
|
authFilePrefixes: ['kimi-'],
|
||||||
tokenTypeValues: ['kimi'],
|
tokenTypeValues: ['kimi'],
|
||||||
aliases: ['moonshot'],
|
aliases: ['moonshot'],
|
||||||
@@ -142,6 +159,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
|
|||||||
callbackProviderName: 'cursor',
|
callbackProviderName: 'cursor',
|
||||||
authUrlProviderName: 'cursor',
|
authUrlProviderName: 'cursor',
|
||||||
refreshOwnership: 'cliproxy',
|
refreshOwnership: 'cliproxy',
|
||||||
|
authStartSupport: 'cliproxy-cli',
|
||||||
authFilePrefixes: ['cursor.', 'cursor-'],
|
authFilePrefixes: ['cursor.', 'cursor-'],
|
||||||
tokenTypeValues: ['cursor'],
|
tokenTypeValues: ['cursor'],
|
||||||
aliases: [],
|
aliases: [],
|
||||||
@@ -154,6 +172,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
|
|||||||
callbackProviderName: 'gitlab',
|
callbackProviderName: 'gitlab',
|
||||||
authUrlProviderName: 'gitlab',
|
authUrlProviderName: 'gitlab',
|
||||||
refreshOwnership: 'cliproxy',
|
refreshOwnership: 'cliproxy',
|
||||||
|
authStartSupport: 'cliproxy-cli',
|
||||||
authFilePrefixes: ['gitlab-'],
|
authFilePrefixes: ['gitlab-'],
|
||||||
tokenTypeValues: ['gitlab'],
|
tokenTypeValues: ['gitlab'],
|
||||||
aliases: ['gitlab-duo'],
|
aliases: ['gitlab-duo'],
|
||||||
@@ -166,6 +185,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
|
|||||||
callbackProviderName: 'codebuddy',
|
callbackProviderName: 'codebuddy',
|
||||||
authUrlProviderName: 'codebuddy',
|
authUrlProviderName: 'codebuddy',
|
||||||
refreshOwnership: 'cliproxy',
|
refreshOwnership: 'cliproxy',
|
||||||
|
authStartSupport: 'cliproxy-cli',
|
||||||
authFilePrefixes: ['codebuddy-'],
|
authFilePrefixes: ['codebuddy-'],
|
||||||
tokenTypeValues: ['codebuddy'],
|
tokenTypeValues: ['codebuddy'],
|
||||||
aliases: ['tencent'],
|
aliases: ['tencent'],
|
||||||
@@ -178,6 +198,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
|
|||||||
callbackProviderName: 'kilo',
|
callbackProviderName: 'kilo',
|
||||||
authUrlProviderName: 'kilo',
|
authUrlProviderName: 'kilo',
|
||||||
refreshOwnership: 'unsupported',
|
refreshOwnership: 'unsupported',
|
||||||
|
authStartSupport: 'cliproxy-cli',
|
||||||
authFilePrefixes: ['kilo-'],
|
authFilePrefixes: ['kilo-'],
|
||||||
tokenTypeValues: ['kilo'],
|
tokenTypeValues: ['kilo'],
|
||||||
aliases: [],
|
aliases: [],
|
||||||
@@ -190,6 +211,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
|
|||||||
callbackProviderName: 'qoder',
|
callbackProviderName: 'qoder',
|
||||||
authUrlProviderName: 'qoder',
|
authUrlProviderName: 'qoder',
|
||||||
refreshOwnership: 'unsupported',
|
refreshOwnership: 'unsupported',
|
||||||
|
authStartSupport: 'cliproxy-cli',
|
||||||
authFilePrefixes: ['qoder-'],
|
authFilePrefixes: ['qoder-'],
|
||||||
tokenTypeValues: ['qoder'],
|
tokenTypeValues: ['qoder'],
|
||||||
aliases: [],
|
aliases: [],
|
||||||
@@ -254,7 +276,7 @@ export function buildProviderAliasMap(
|
|||||||
|
|
||||||
const existingProvider = aliasMap.get(normalized);
|
const existingProvider = aliasMap.get(normalized);
|
||||||
if (existingProvider && existingProvider !== provider) {
|
if (existingProvider && existingProvider !== provider) {
|
||||||
throw new Error(
|
throw new ConfigError(
|
||||||
`Provider alias collision for "${normalized}": ${existingProvider} and ${provider}`
|
`Provider alias collision for "${normalized}": ${existingProvider} and ${provider}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -330,6 +352,17 @@ export function isRefreshDelegatedToCLIProxy(provider: CLIProxyProvider): boolea
|
|||||||
return PROVIDER_CAPABILITIES[provider].refreshOwnership === 'cliproxy';
|
return PROVIDER_CAPABILITIES[provider].refreshOwnership === 'cliproxy';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getUnsupportedAuthStartReason(provider: CLIProxyProvider): string | null {
|
||||||
|
const capabilities = PROVIDER_CAPABILITIES[provider];
|
||||||
|
if (capabilities.authStartSupport !== 'unsupported') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
capabilities.authStartUnsupportedReason ??
|
||||||
|
`${capabilities.displayName} account linking is not supported by the bundled CLIProxy runtime.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getProviderAuthFilePrefixes(provider: CLIProxyProvider): readonly string[] {
|
export function getProviderAuthFilePrefixes(provider: CLIProxyProvider): readonly string[] {
|
||||||
return PROVIDER_CAPABILITIES[provider].authFilePrefixes;
|
return PROVIDER_CAPABILITIES[provider].authFilePrefixes;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,12 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import { ToolSanitizationProxy } from '../tool-sanitization-proxy';
|
import { ToolSanitizationProxy } from '../tool-sanitization-proxy';
|
||||||
|
import { CodexReasoningProxy } from '../../ai-providers/codex-reasoning-proxy';
|
||||||
|
|
||||||
// Mock upstream server that echoes requests
|
// Mock upstream server that echoes requests
|
||||||
let mockUpstream: http.Server;
|
let mockUpstream: http.Server;
|
||||||
let mockUpstreamPort: number;
|
let mockUpstreamPort: number;
|
||||||
let lastRequest: { body: unknown; headers: http.IncomingHttpHeaders } | null = null;
|
let lastRequest: { path: string; body: unknown; headers: http.IncomingHttpHeaders } | null = null;
|
||||||
|
|
||||||
// Track response to send back
|
// Track response to send back
|
||||||
let mockResponse: { status: number; body: unknown; stream?: boolean } = {
|
let mockResponse: { status: number; body: unknown; stream?: boolean } = {
|
||||||
@@ -28,6 +29,7 @@ beforeAll(async () => {
|
|||||||
req.on('end', () => {
|
req.on('end', () => {
|
||||||
const body = Buffer.concat(chunks).toString('utf8');
|
const body = Buffer.concat(chunks).toString('utf8');
|
||||||
lastRequest = {
|
lastRequest = {
|
||||||
|
path: req.url || '',
|
||||||
body: body ? JSON.parse(body) : null,
|
body: body ? JSON.parse(body) : null,
|
||||||
headers: req.headers,
|
headers: req.headers,
|
||||||
};
|
};
|
||||||
@@ -184,6 +186,50 @@ describe('ToolSanitizationProxy Integration', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes codex effort aliases through the provider-scoped local proxy chain', async () => {
|
||||||
|
const toolProxy = new ToolSanitizationProxy({
|
||||||
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
||||||
|
});
|
||||||
|
const toolPort = await toolProxy.start();
|
||||||
|
const reasoningProxy = new CodexReasoningProxy({
|
||||||
|
upstreamBaseUrl: `http://127.0.0.1:${toolPort}`,
|
||||||
|
modelMap: {
|
||||||
|
defaultModel: 'gpt-5.5-high',
|
||||||
|
opusModel: 'gpt-5.5-xhigh',
|
||||||
|
sonnetModel: 'gpt-5.5-high',
|
||||||
|
haikuModel: 'gpt-5.5-mini-medium',
|
||||||
|
},
|
||||||
|
defaultEffort: 'medium',
|
||||||
|
});
|
||||||
|
const reasoningPort = await reasoningProxy.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`http://127.0.0.1:${reasoningPort}/api/provider/codex/v1/messages`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'gpt-5.5-high',
|
||||||
|
messages: [{ role: 'user', content: 'hi' }],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
expect(lastRequest).not.toBeNull();
|
||||||
|
expect(lastRequest!.path).toBe('/api/provider/codex/v1/messages');
|
||||||
|
expect((lastRequest!.body as Record<string, unknown>).model).toBe('gpt-5.5');
|
||||||
|
expect(
|
||||||
|
((lastRequest!.body as Record<string, unknown>).reasoning as Record<string, unknown>)
|
||||||
|
.effort
|
||||||
|
).toBe('high');
|
||||||
|
} finally {
|
||||||
|
reasoningProxy.stop();
|
||||||
|
toolProxy.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('normalizes dotted Claude thinking model IDs for root/composite routes', async () => {
|
it('normalizes dotted Claude thinking model IDs for root/composite routes', async () => {
|
||||||
const proxy = new ToolSanitizationProxy({
|
const proxy = new ToolSanitizationProxy({
|
||||||
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO
|
|||||||
gemini: 'Google Gemini via CLIProxy OAuth',
|
gemini: 'Google Gemini via CLIProxy OAuth',
|
||||||
codex: 'OpenAI Codex via CLIProxy OAuth',
|
codex: 'OpenAI Codex via CLIProxy OAuth',
|
||||||
agy: 'Antigravity via CLIProxy OAuth',
|
agy: 'Antigravity via CLIProxy OAuth',
|
||||||
qwen: 'Qwen Code via CLIProxy OAuth',
|
qwen: 'Qwen Code via CLIProxy; account linking unsupported',
|
||||||
iflow: 'iFlow via CLIProxy OAuth',
|
iflow: 'iFlow via CLIProxy OAuth',
|
||||||
kiro: 'Kiro via CLIProxy OAuth',
|
kiro: 'Kiro via CLIProxy OAuth',
|
||||||
ghcp: 'Deprecated GitHub Copilot via CLIProxy OAuth',
|
ghcp: 'Deprecated GitHub Copilot via CLIProxy OAuth',
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
} from '../../cliproxy/auth/auth-types';
|
} from '../../cliproxy/auth/auth-types';
|
||||||
import {
|
import {
|
||||||
getOAuthFlowType,
|
getOAuthFlowType,
|
||||||
|
getUnsupportedAuthStartReason,
|
||||||
isBrowserUrlAuthProvider,
|
isBrowserUrlAuthProvider,
|
||||||
mapExternalProviderName,
|
mapExternalProviderName,
|
||||||
} from '../../cliproxy/provider-capabilities';
|
} from '../../cliproxy/provider-capabilities';
|
||||||
@@ -285,6 +286,11 @@ export function getStartUrlUnsupportedReason(
|
|||||||
provider: CLIProxyProvider,
|
provider: CLIProxyProvider,
|
||||||
options?: { kiroMethod?: KiroAuthMethod }
|
options?: { kiroMethod?: KiroAuthMethod }
|
||||||
): string | null {
|
): string | null {
|
||||||
|
const unsupportedAuthStartReason = getStartAuthUnsupportedReason(provider);
|
||||||
|
if (unsupportedAuthStartReason) {
|
||||||
|
return unsupportedAuthStartReason;
|
||||||
|
}
|
||||||
|
|
||||||
if (provider === 'kiro') {
|
if (provider === 'kiro') {
|
||||||
const kiroMethod = options?.kiroMethod ?? normalizeKiroAuthMethod();
|
const kiroMethod = options?.kiroMethod ?? normalizeKiroAuthMethod();
|
||||||
if (kiroMethod === 'idc') {
|
if (kiroMethod === 'idc') {
|
||||||
@@ -316,6 +322,10 @@ export function getStartAuthFailureMessage(provider: CLIProxyProvider): string {
|
|||||||
return 'Authentication failed or was cancelled';
|
return 'Authentication failed or was cancelled';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStartAuthUnsupportedReason(provider: CLIProxyProvider): string | null {
|
||||||
|
return getUnsupportedAuthStartReason(provider);
|
||||||
|
}
|
||||||
|
|
||||||
function getManualCallbackRegistrationError(provider: CLIProxyProvider): string {
|
function getManualCallbackRegistrationError(provider: CLIProxyProvider): string {
|
||||||
if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) {
|
if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) {
|
||||||
return 'Authenticated token could not be matched to a new account. Retry the flow and choose a different nickname if needed.';
|
return 'Authenticated token could not be matched to a new account. Retry the flow and choose a different nickname if needed.';
|
||||||
@@ -683,6 +693,12 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const localProvider = provider as CLIProxyProvider;
|
const localProvider = provider as CLIProxyProvider;
|
||||||
|
const unsupportedReason = getStartAuthUnsupportedReason(localProvider);
|
||||||
|
if (unsupportedReason) {
|
||||||
|
res.status(400).json({ error: unsupportedReason, code: 'AUTH_START_UNSUPPORTED' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const existingAccounts = getProviderAccounts(localProvider);
|
const existingAccounts = getProviderAccounts(localProvider);
|
||||||
const reauthTarget = getReauthAccountTarget(accountId, existingAccounts);
|
const reauthTarget = getReauthAccountTarget(accountId, existingAccounts);
|
||||||
if (reauthTarget.error) {
|
if (reauthTarget.error) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
getReauthAccountTarget,
|
getReauthAccountTarget,
|
||||||
getStartAuthFailureMessage,
|
getStartAuthFailureMessage,
|
||||||
getStartAuthNicknameError,
|
getStartAuthNicknameError,
|
||||||
|
getStartAuthUnsupportedReason,
|
||||||
getStartUrlUnsupportedReason,
|
getStartUrlUnsupportedReason,
|
||||||
} from '../../../src/web-server/routes/cliproxy-auth-routes';
|
} from '../../../src/web-server/routes/cliproxy-auth-routes';
|
||||||
|
|
||||||
@@ -13,12 +14,14 @@ describe('cliproxy-auth-routes start-url guard', () => {
|
|||||||
"Kiro method 'aws' uses Device Code flow"
|
"Kiro method 'aws' uses Device Code flow"
|
||||||
);
|
);
|
||||||
expect(getStartUrlUnsupportedReason('ghcp')).toContain("Provider 'ghcp' uses Device Code flow");
|
expect(getStartUrlUnsupportedReason('ghcp')).toContain("Provider 'ghcp' uses Device Code flow");
|
||||||
expect(getStartUrlUnsupportedReason('qwen')).toContain("Provider 'qwen' uses Device Code flow");
|
expect(getStartUrlUnsupportedReason('qwen')).toContain('Qwen account linking is not supported');
|
||||||
expect(getStartUrlUnsupportedReason('codebuddy')).toContain(
|
expect(getStartUrlUnsupportedReason('codebuddy')).toContain(
|
||||||
"Provider 'codebuddy' uses Device Code flow"
|
"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");
|
||||||
expect(getStartUrlUnsupportedReason('qoder')).toContain("Provider 'qoder' uses Device Code flow");
|
expect(getStartUrlUnsupportedReason('qoder')).toContain(
|
||||||
|
"Provider 'qoder' uses Device Code flow"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows Cursor browser URL auth on start-url', () => {
|
it('allows Cursor browser URL auth on start-url', () => {
|
||||||
@@ -89,6 +92,13 @@ describe('cliproxy-auth-routes Kiro IDC start validation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('cliproxy-auth-routes start failure messaging', () => {
|
describe('cliproxy-auth-routes start failure messaging', () => {
|
||||||
|
it('returns a clear unsupported message for Qwen account linking', () => {
|
||||||
|
expect(getStartAuthUnsupportedReason('qwen')).toContain(
|
||||||
|
'Qwen account linking is not supported'
|
||||||
|
);
|
||||||
|
expect(getStartAuthUnsupportedReason('kiro')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('returns ghcp-specific guidance for Copilot verification failures', () => {
|
it('returns ghcp-specific guidance for Copilot verification failures', () => {
|
||||||
expect(getStartAuthFailureMessage('ghcp')).toContain(
|
expect(getStartAuthFailureMessage('ghcp')).toContain(
|
||||||
'GitHub Copilot verification did not complete'
|
'GitHub Copilot verification did not complete'
|
||||||
|
|||||||
Reference in new issue
Block a user