mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
feat(ClinePass): add gateway provider with usage support (#1818)
* feat(integrations): add ClinePass gateway provider with usage support Adds ClinePass (https://cline.bot) as an OpenAI-compatible gateway provider. Gateway - New descriptor at src/integrations/gateways/clinepass.ts with 10 static models. - Uses wireFormat: 'reasoning_effort' and full granular levels (low/medium/high/xhigh) so each model can expose reasoning controls consistent with Atlas Cloud. - Dedicated credentials only: requires CLINE_API_KEY and ignores stale OPENAI_API_KEY. - Generated integration artifacts updated via bun run integrations:generate. /usage support - New service module under src/services/api/clinepassUsage/ for types, fetching, and normalizing the ClinePass usage-limits response. - New UI component src/components/Settings/ClinePassUsage.tsx rendered by Usage.tsx. - Displays 5-hour, weekly, and monthly usage progress bars with longer progress bars. Provider profile fixes - routeMetadata.ts now resolves the active provider from the saved profile even when CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED is not yet set, so /usage works immediately after switching providers with /provider. - providerProfile.ts / providerProfiles.ts learn CLINE_API_KEY so saved profiles apply the ClinePass credential alongside the OpenAI-compatible env vars. Tests & docs - Updated ProviderManager.test.tsx and compatibility.test.ts for the new preset. - Added routeMetadata.test.ts cases for ClinePass and generic profile fallback. - Added clinepassUsage.test.ts for payload normalization and row building. - Updated .env.example and README.md with CLINE_API_KEY instructions. Validation - bun run typecheck - bun run build - bun run test:provider (964 pass) - bun run check (5331 pass; 1 unrelated Windows file-mode failure in branch.test.ts) * fixup: wire CLINE_API_KEY/CLINE_API_MODEL into runtime routing and profile persistence - Add 'clinepass' to the ProviderProfile union so saved profiles can use it. - Add CLINE_API_KEY to PROFILE_ENV_KEYS so profile switching clears stale keys. - routeMetadata.ts: env-only CLINE_API_KEY now selects the clinepass route; other env-only intents treat CLINE_API_KEY as a competing credential. Added isClinePassBaseUrl/getClinePassBaseUrlOverride and activeProfileBaseUrl option to resolveActiveRouteIdFromEnv so custom/unknown profiles targeting api.cline.bot resolve correctly. - providerConfig.ts: resolveProviderRequest now reads CLINE_API_MODEL when CLINE_API_KEY is present and defaults the base URL to https://api.cline.bot/api/v1. - providerProfiles.ts: CLINE_API_KEY is mirrored into startup/profile env for clinepass and custom profiles at api.cline.bot, and is checked in isProcessEnvAlignedWithProfile. - parse.ts: toIsoDate drops invalid resetsAt values instead of echoing them. - Add fetchClinePassUsage tests covering auth, headers, non-OK responses, and errors. - Add routeMetadata and providerConfig regression tests for ClinePass env/model wiring. - Add providerProfiles tests for CLINE_API_KEY propagation in apply/env and startup persistence. - Remove extra blank line in .env.example. Validation: - bun run typecheck - bun run build - bun run test:provider (972 pass) - bun run check (5346 pass; 1 unrelated Windows file-mode failure in branch.test.ts) * fixup: address PR review findings for ClinePass env routing and profile persistence - routeMetadata.ts: Let the concrete OPENAI_BASE_URL match win before falling back to activeProfileProvider / activeProfileBaseUrl. Prevents a saved ClinePass profile from overriding an explicit CLAUDE_CODE_USE_OPENAI env pointing at another gateway. - providerConfig.ts: Gate the ClinePass model branch behind !isGithubMode so a stale CLINE_API_KEY/CLINE_API_MODEL does not override GitHub Copilot model selection. - providerProfiles.ts: Introduce isClinePassProfile() predicate that uses route resolution (routeId === 'clinepass' || baseUrl includes api.cline.bot) and share it across live application, alignment, and startup persistence paths so saved ClinePass profiles keep the dedicated credential consistently even with non-default base URLs. - Add regression tests covering all three findings. Validation: - bun run typecheck - bun run build - bun run test:provider (973 pass) * fixup: use hostname-based ClinePass detection in providerProfiles Replace includes('api.cline.bot') substring matching with isClinePassBaseUrl which validates the exact hostname via URL parsing, preventing spoofed hosts like api.cline.bot.evil.example from triggering CLINE_API_KEY mirroring. Validation: - bun run typecheck - bun run build - bun run test:provider (973 pass) * fixup: gate ClinePass model selection on resolved base URL Move base URL resolution before model selection in resolveProviderRequest so effectiveClinePassMode is only active when no explicit non-ClinePass base URL is set via options.baseUrl, OPENAI_BASE_URL, or OPENAI_API_BASE. Previously CLINE_API_KEY=cp-key + CLINE_API_MODEL=cline-pass/qwen3.7-max + OPENAI_BASE_URL=https://api.openai.com/v1 would return requestedModel=cline-pass/qwen3.7-max with baseUrl=https://api.openai.com/v1, sending a ClinePass model ID to a non-Cline provider. Now the resolver returns the correct OPENAI_MODEL and OpenAI base URL in that scenario, and only uses ClinePass model/default-base when the resolved base URL is absent or actually api.cline.bot. Added regression tests for: - CLINE_API_KEY + CLINE_API_MODEL + explicit OPENAI_BASE_URL - CLINE_API_KEY + CLINE_API_MODEL + explicit baseUrl option - CLINE_API_KEY + CLINE_API_MODEL with no base URL (ClinePass default) Validation: - bun run typecheck - bun run build - bun run test:provider (976 pass) * fixup: default ClinePass model for blank env Treat whitespace-only CLINE_API_MODEL as unset so OPENAI_MODEL can still provide the ClinePass model override. When CLINE_API_KEY selects ClinePass without any model env, fall back to the ClinePass route default instead of the generic codexplan alias. Validation: - timeout 600 bun test src/services/api/providerConfig.test.ts - timeout 600 bun test src/services/api/clinepassUsage.test.ts src/integrations/routeMetadata.test.ts src/services/api/providerConfig.test.ts src/utils/providerProfiles.test.ts src/integrations/compatibility.test.ts src/components/ProviderManager.test.tsx - timeout 600 bun test src/services/api/providerConfig.test.ts src/services/api/client.test.ts src/integrations/routeMetadata.test.ts src/integrations/runtimeMetadata.test.ts src/services/api/clinepassUsage.test.ts src/services/api/minimaxUsage.test.ts src/utils/providerProfiles.test.ts - timeout 600 bun run integrations:check - timeout 600 bun run typecheck - timeout 600 bun run build - timeout 600 bun run security:pr-scan - git diff --check origin/main...HEAD
This commit is contained in:
@@ -352,6 +352,18 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
|
||||
# OPENAI_MODEL=accounts/fireworks/models/llama-v3p1-70b-instruct
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Option 13: ClinePass
|
||||
# -----------------------------------------------------------------------------
|
||||
# ClinePass is an AI model gateway with usage limits (5hr, weekly, monthly).
|
||||
# Get your API key from https://app.cline.bot/dashboard/subscription?personal=true
|
||||
# Models include DeepSeek, Qwen, GLM, Kimi, MiMo, and MiniMax.
|
||||
#
|
||||
# CLINE_API_KEY=your-cline-api-key-here
|
||||
# CLINE_API_MODEL=cline-pass/deepseek-v4-flash (optional, default)
|
||||
#
|
||||
# Usage limits are displayed via /usage command.
|
||||
|
||||
# =============================================================================
|
||||
# OPTIONAL TUNING
|
||||
# =============================================================================
|
||||
|
||||
@@ -226,6 +226,7 @@ Advanced and source-build guides:
|
||||
| Z.AI GLM Coding Plan | `/provider` or OpenAI-compatible env vars | Uses `OPENAI_API_KEY` at `https://api.z.ai/api/coding/paas/v4` and defaults to `glm-5.2` |
|
||||
| Hicap | `/provider` or OpenAI-compatible env vars | Uses `api-key` auth, discovers models from unauthenticated `/models`, and supports Responses mode for `gpt-` models |
|
||||
| Fireworks AI | `/provider` or env vars | First-class provider with 276 curated models (DeepSeek, Qwen, Llama, Gemma, and more); uses `FIREWORKS_API_KEY` |
|
||||
| ClinePass | `/provider` or env vars | AI model gateway with usage limits (5hr, weekly, monthly); uses `CLINE_API_KEY` at `https://api.cline.bot/api/v1` |
|
||||
| Gemini | `/provider` or env vars | Supports API key only |
|
||||
| GitHub Models | `/onboard-github` | Interactive onboarding with saved credentials |
|
||||
| Codex OAuth | `/provider` | Opens ChatGPT sign-in in your browser and stores Codex credentials securely |
|
||||
|
||||
@@ -47,6 +47,7 @@ export function resolveActiveUsageId(
|
||||
processEnv: NodeJS.ProcessEnv = process.env,
|
||||
options?: {
|
||||
activeProfileProvider?: string
|
||||
activeProfileBaseUrl?: string
|
||||
providerCategory?: APIProvider | string
|
||||
},
|
||||
): APIProvider | string {
|
||||
@@ -62,6 +63,7 @@ export function resolveActiveUsageId(
|
||||
|
||||
const routeId = resolveActiveRouteIdFromEnv(processEnv, {
|
||||
activeProfileProvider: options?.activeProfileProvider,
|
||||
activeProfileBaseUrl: options?.activeProfileBaseUrl,
|
||||
})
|
||||
|
||||
return routeId ?? providerCategory ?? 'firstParty'
|
||||
|
||||
@@ -116,6 +116,7 @@ const PRESET_ORDER = [
|
||||
'Atlas Cloud',
|
||||
'Azure OpenAI',
|
||||
'Bankr',
|
||||
'ClinePass',
|
||||
'DeepSeek',
|
||||
'Codex OAuth',
|
||||
'xAI OAuth (Grok)',
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import { Box, Text } from '../../ink.js'
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js'
|
||||
import {
|
||||
buildClinePassUsageRows,
|
||||
fetchClinePassUsage,
|
||||
type ClinePassUsageData,
|
||||
type ClinePassUsageRow,
|
||||
} from '../../services/api/clinepassUsage.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'
|
||||
import { Byline } from '../design-system/Byline.js'
|
||||
import { ProgressBar } from '../design-system/ProgressBar.js'
|
||||
|
||||
const RESET_COUNTDOWN_REFRESH_MS = 30_000
|
||||
const PROGRESS_BAR_WIDTH = 40
|
||||
|
||||
type ClinePassUsageLimitBarProps = {
|
||||
label: string
|
||||
usedPercent: number
|
||||
resetsAt?: string
|
||||
maxWidth: number
|
||||
nowMs: number
|
||||
}
|
||||
|
||||
function formatCountdownDuration(ms: number): string {
|
||||
const totalMinutes = Math.max(1, Math.ceil(ms / 60_000))
|
||||
const days = Math.floor(totalMinutes / 1_440)
|
||||
const hours = Math.floor((totalMinutes % 1_440) / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
|
||||
if (days > 0) {
|
||||
return hours > 0 ? `${days}d ${hours}h` : `${days}d`
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`
|
||||
}
|
||||
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
function formatResetCountdown(
|
||||
resetsAt: string | undefined,
|
||||
nowMs: number,
|
||||
): string | undefined {
|
||||
if (!resetsAt) return undefined
|
||||
|
||||
const resetMs = Date.parse(resetsAt)
|
||||
if (!Number.isFinite(resetMs)) return undefined
|
||||
|
||||
const remainingMs = resetMs - nowMs
|
||||
if (remainingMs <= 0) {
|
||||
return 'Resetting now'
|
||||
}
|
||||
|
||||
return `Resets in ${formatCountdownDuration(remainingMs)}`
|
||||
}
|
||||
|
||||
function ClinePassUsageLimitBar({
|
||||
label,
|
||||
usedPercent,
|
||||
resetsAt,
|
||||
maxWidth,
|
||||
nowMs,
|
||||
}: ClinePassUsageLimitBarProps): React.ReactNode {
|
||||
const normalizedUsedPercent = Math.max(0, Math.min(100, usedPercent))
|
||||
const usedText = `${Math.floor(normalizedUsedPercent)}% used`
|
||||
const resetText = formatResetCountdown(resetsAt, nowMs)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
<Text bold>{label}</Text>
|
||||
{resetText ? <Text dimColor> · {resetText}</Text> : null}
|
||||
</Text>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<ProgressBar
|
||||
ratio={normalizedUsedPercent / 100}
|
||||
width={Math.min(PROGRESS_BAR_WIDTH, Math.max(1, maxWidth))}
|
||||
fillColor="rate_limit_fill"
|
||||
emptyColor="rate_limit_empty"
|
||||
/>
|
||||
<Text dimColor>{usedText}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function ClinePassUsage(): React.ReactNode {
|
||||
const [usage, setUsage] = useState<ClinePassUsageData | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [nowMs, setNowMs] = useState(() => Date.now())
|
||||
const { columns } = useTerminalSize()
|
||||
const availableWidth = columns - 2
|
||||
const maxWidth = Math.min(availableWidth, 80)
|
||||
|
||||
const loadUsage = React.useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
setUsage(await fetchClinePassUsage())
|
||||
} catch (err) {
|
||||
logError(err as Error)
|
||||
setError(
|
||||
err instanceof Error ? err.message : 'Failed to load ClinePass usage',
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadUsage()
|
||||
}, [loadUsage])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setNowMs(Date.now())
|
||||
}, RESET_COUNTDOWN_REFRESH_MS)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
useKeybinding(
|
||||
'settings:retry',
|
||||
() => {
|
||||
void loadUsage()
|
||||
},
|
||||
{
|
||||
context: 'Settings',
|
||||
isActive: !!error && !isLoading,
|
||||
},
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="error">Error: {error}</Text>
|
||||
<Text dimColor>
|
||||
<Byline>
|
||||
<ConfigurableShortcutHint
|
||||
action="settings:retry"
|
||||
context="Settings"
|
||||
fallback="r"
|
||||
description="retry"
|
||||
/>
|
||||
<ConfigurableShortcutHint
|
||||
action="confirm:no"
|
||||
context="Settings"
|
||||
fallback="Esc"
|
||||
description="cancel"
|
||||
/>
|
||||
</Byline>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (!usage) {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text dimColor>Loading ClinePass usage data…</Text>
|
||||
<Text dimColor>
|
||||
<ConfigurableShortcutHint
|
||||
action="confirm:no"
|
||||
context="Settings"
|
||||
fallback="Esc"
|
||||
description="cancel"
|
||||
/>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const rows =
|
||||
usage.availability === 'available'
|
||||
? buildClinePassUsageRows(usage.windows)
|
||||
: []
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1} width="100%">
|
||||
{usage.planName ? <Text dimColor>Plan: {usage.planName}</Text> : null}
|
||||
|
||||
{usage.availability === 'unknown' ? (
|
||||
<Text dimColor>{usage.message}</Text>
|
||||
) : rows.length === 0 ? (
|
||||
<Text dimColor>
|
||||
No ClinePass usage windows were returned for this account.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{rows.map((row, index) => (
|
||||
<ClinePassUsageLimitBar
|
||||
key={`${row.label}-${index}`}
|
||||
label={row.label}
|
||||
usedPercent={row.usedPercent}
|
||||
resetsAt={row.resetsAt}
|
||||
maxWidth={maxWidth}
|
||||
nowMs={nowMs}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Text dimColor>
|
||||
<ConfigurableShortcutHint
|
||||
action="confirm:no"
|
||||
context="Settings"
|
||||
fallback="Esc"
|
||||
description="cancel"
|
||||
/>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { Byline } from '../design-system/Byline.js';
|
||||
import { ProgressBar } from '../design-system/ProgressBar.js';
|
||||
import { isEligibleForOverageCreditGrant, OverageCreditUpsell } from '../LogoV2/OverageCreditUpsell.js';
|
||||
import { CodexUsage } from './CodexUsage.js';
|
||||
import { ClinePassUsage } from './ClinePassUsage.js';
|
||||
import { MiniMaxUsage } from './MiniMaxUsage.js';
|
||||
import { UnsupportedUsage } from './UnsupportedUsage.js';
|
||||
type LimitBarProps = {
|
||||
@@ -277,6 +278,7 @@ export function Usage(): React.ReactNode {
|
||||
const activeProfile = getActiveProviderProfile();
|
||||
const usageDescriptor = getUsageDescriptor(resolveActiveUsageId(process.env, {
|
||||
activeProfileProvider: activeProfile?.provider,
|
||||
activeProfileBaseUrl: activeProfile?.baseUrl,
|
||||
providerCategory: provider,
|
||||
}));
|
||||
if (provider === 'codex') {
|
||||
@@ -285,6 +287,9 @@ export function Usage(): React.ReactNode {
|
||||
if (usageDescriptor.resolvedId === 'minimax' && usageDescriptor.supported) {
|
||||
return <MiniMaxUsage />;
|
||||
}
|
||||
if (usageDescriptor.resolvedId === 'clinepass' && usageDescriptor.supported) {
|
||||
return <ClinePassUsage />;
|
||||
}
|
||||
if (usageDescriptor.resolvedId === 'anthropic' && usageDescriptor.supported) {
|
||||
return <AnthropicUsage />;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ const EXPECTED_PRESETS = [
|
||||
'fireworks',
|
||||
'opencode',
|
||||
'opencode-go',
|
||||
'clinepass',
|
||||
] as const satisfies readonly ProviderPreset[]
|
||||
|
||||
describe('compatibility mappings', () => {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { defineGateway } from '../define.js'
|
||||
|
||||
export default defineGateway({
|
||||
id: 'clinepass',
|
||||
label: 'ClinePass',
|
||||
vendorId: 'openai',
|
||||
category: 'hosted',
|
||||
defaultBaseUrl: 'https://api.cline.bot/api/v1',
|
||||
defaultModel: 'cline-pass/deepseek-v4-flash',
|
||||
setup: {
|
||||
requiresAuth: true,
|
||||
authMode: 'api-key',
|
||||
credentialEnvVars: ['CLINE_API_KEY'],
|
||||
dedicatedCredentialsOnly: true,
|
||||
},
|
||||
transportConfig: {
|
||||
kind: 'openai-compatible',
|
||||
},
|
||||
preset: {
|
||||
id: 'clinepass',
|
||||
vendorId: 'openai',
|
||||
description: 'ClinePass - AI model gateway with usage limits',
|
||||
apiKeyEnvVars: ['CLINE_API_KEY'],
|
||||
modelEnvVars: ['CLINE_API_MODEL', 'OPENAI_MODEL'],
|
||||
},
|
||||
validation: {
|
||||
kind: 'credential-env',
|
||||
routing: {
|
||||
matchDefaultBaseUrl: true,
|
||||
},
|
||||
credentialEnvVars: ['CLINE_API_KEY'],
|
||||
missingCredentialMessage:
|
||||
'ClinePass auth is required. Set CLINE_API_KEY.',
|
||||
},
|
||||
catalog: {
|
||||
source: 'static',
|
||||
models: [
|
||||
{
|
||||
id: 'cline-pass/qwen3.7-max',
|
||||
apiName: 'cline-pass/qwen3.7-max',
|
||||
aliases: ['qwen3.7-max'],
|
||||
modelDescriptorId: 'qwen3.7-max',
|
||||
label: 'Qwen3.7 Max',
|
||||
contextWindow: 1_000_000,
|
||||
maxOutputTokens: 67_072,
|
||||
capabilities: {
|
||||
supportsFunctionCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
reasoning: {
|
||||
mode: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
wireFormat: 'reasoning_effort',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cline-pass/qwen3.7-plus',
|
||||
apiName: 'cline-pass/qwen3.7-plus',
|
||||
aliases: ['qwen3.7-plus'],
|
||||
modelDescriptorId: 'qwen3.7-plus',
|
||||
label: 'Qwen3.7 Plus',
|
||||
contextWindow: 1_000_000,
|
||||
maxOutputTokens: 67_072,
|
||||
capabilities: {
|
||||
supportsVision: true,
|
||||
supportsFunctionCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
reasoning: {
|
||||
mode: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
wireFormat: 'reasoning_effort',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cline-pass/minimax-m3',
|
||||
apiName: 'cline-pass/minimax-m3',
|
||||
aliases: ['minimax-m3'],
|
||||
modelDescriptorId: 'minimax-m3',
|
||||
label: 'MiniMax M3',
|
||||
contextWindow: 524_288,
|
||||
maxOutputTokens: 524_288,
|
||||
capabilities: {
|
||||
supportsVision: true,
|
||||
supportsFunctionCalling: true,
|
||||
supportsReasoning: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cline-pass/glm-5.2',
|
||||
apiName: 'cline-pass/glm-5.2',
|
||||
aliases: ['glm-5.2'],
|
||||
modelDescriptorId: 'glm-5.2',
|
||||
label: 'GLM 5.2',
|
||||
contextWindow: 202_752,
|
||||
maxOutputTokens: 202_752,
|
||||
capabilities: {
|
||||
supportsVision: true,
|
||||
supportsFunctionCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
reasoning: {
|
||||
mode: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
wireFormat: 'reasoning_effort',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cline-pass/kimi-k2.7-code',
|
||||
apiName: 'cline-pass/kimi-k2.7-code',
|
||||
aliases: ['kimi-k2.7-code'],
|
||||
modelDescriptorId: 'kimi-k2.7-code',
|
||||
label: 'Kimi K2.7 Code',
|
||||
contextWindow: 262_144,
|
||||
maxOutputTokens: 32_768,
|
||||
capabilities: {
|
||||
supportsVision: true,
|
||||
supportsFunctionCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
reasoning: {
|
||||
mode: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
wireFormat: 'reasoning_effort',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cline-pass/kimi-k2.6',
|
||||
apiName: 'cline-pass/kimi-k2.6',
|
||||
aliases: ['kimi-k2.6'],
|
||||
modelDescriptorId: 'kimi-k2.6',
|
||||
label: 'Kimi K2.6',
|
||||
contextWindow: 262_144,
|
||||
maxOutputTokens: 262_144,
|
||||
capabilities: {
|
||||
supportsVision: true,
|
||||
supportsFunctionCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
reasoning: {
|
||||
mode: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
wireFormat: 'reasoning_effort',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cline-pass/deepseek-v4-pro',
|
||||
apiName: 'cline-pass/deepseek-v4-pro',
|
||||
aliases: ['deepseek-v4-pro'],
|
||||
modelDescriptorId: 'deepseek-v4-pro',
|
||||
label: 'DeepSeek V4 Pro',
|
||||
contextWindow: 1_048_576,
|
||||
maxOutputTokens: 393_216,
|
||||
capabilities: {
|
||||
supportsVision: true,
|
||||
supportsFunctionCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
reasoning: {
|
||||
mode: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
wireFormat: 'reasoning_effort',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cline-pass/deepseek-v4-flash',
|
||||
apiName: 'cline-pass/deepseek-v4-flash',
|
||||
aliases: ['deepseek-v4-flash'],
|
||||
modelDescriptorId: 'deepseek-v4-flash',
|
||||
label: 'DeepSeek V4 Flash',
|
||||
contextWindow: 1_048_576,
|
||||
maxOutputTokens: 393_216,
|
||||
capabilities: {
|
||||
supportsVision: true,
|
||||
supportsFunctionCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
reasoning: {
|
||||
mode: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
wireFormat: 'reasoning_effort',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cline-pass/mimo-v2.5',
|
||||
apiName: 'cline-pass/mimo-v2.5',
|
||||
aliases: ['mimo-v2.5'],
|
||||
modelDescriptorId: 'mimo-v2.5',
|
||||
label: 'MiMo V2.5',
|
||||
contextWindow: 1_048_576,
|
||||
maxOutputTokens: 65_536,
|
||||
capabilities: {
|
||||
supportsVision: true,
|
||||
supportsFunctionCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
reasoning: {
|
||||
mode: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
wireFormat: 'reasoning_effort',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cline-pass/mimo-v2.5-pro',
|
||||
apiName: 'cline-pass/mimo-v2.5-pro',
|
||||
aliases: ['mimo-v2.5-pro'],
|
||||
modelDescriptorId: 'mimo-v2.5-pro',
|
||||
label: 'MiMo V2.5 Pro',
|
||||
contextWindow: 1_048_576,
|
||||
maxOutputTokens: 65_536,
|
||||
capabilities: {
|
||||
supportsFunctionCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
reasoning: {
|
||||
mode: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
wireFormat: 'reasoning_effort',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
usage: {
|
||||
supported: true,
|
||||
ui: {
|
||||
showResetCountdown: true,
|
||||
compactProgressBar: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -22,6 +22,7 @@ import gatewayAtlasCloud from '../gateways/atlas-cloud.js'
|
||||
import gatewayAtomicChat from '../gateways/atomic-chat.js'
|
||||
import gatewayAzureOpenai from '../gateways/azure-openai.js'
|
||||
import gatewayBedrock from '../gateways/bedrock.js'
|
||||
import gatewayClinepass from '../gateways/clinepass.js'
|
||||
import gatewayCustom from '../gateways/custom.js'
|
||||
import gatewayDashscopeCn from '../gateways/dashscope-cn.js'
|
||||
import gatewayDashscopeIntl from '../gateways/dashscope-intl.js'
|
||||
@@ -76,7 +77,7 @@ import modelXai from '../models/xai.js'
|
||||
import modelXiaomiMimo from '../models/xiaomi-mimo.js'
|
||||
|
||||
export const VENDOR_DESCRIPTORS = [vendorAnthropic, vendorBankr, vendorDeepseek, vendorFireworks, vendorGemini, vendorMinimax, vendorMoonshot, vendorNearai, vendorOpenai, vendorVenice, vendorXai, vendorXiaomiMimo, vendorZai] as const satisfies readonly VendorDescriptor[]
|
||||
export const GATEWAY_DESCRIPTORS = [gatewayAtlasCloud, gatewayAtomicChat, gatewayAzureOpenai, gatewayBedrock, gatewayCustom, gatewayDashscopeCn, gatewayDashscopeIntl, gatewayGithubEnterprise, gatewayGithub, gatewayGitlawbOpengateway, gatewayGroq, gatewayHicap, gatewayKimiCode, gatewayLmstudio, gatewayMistral, gatewayNvidiaNim, gatewayOllama, gatewayOpencodeGo, gatewayOpencode, gatewayOpenrouter, gatewayTogether, gatewayVertex, gatewayXiaomiMimoToken] as const satisfies readonly GatewayDescriptor[]
|
||||
export const GATEWAY_DESCRIPTORS = [gatewayAtlasCloud, gatewayAtomicChat, gatewayAzureOpenai, gatewayBedrock, gatewayClinepass, gatewayCustom, gatewayDashscopeCn, gatewayDashscopeIntl, gatewayGithubEnterprise, gatewayGithub, gatewayGitlawbOpengateway, gatewayGroq, gatewayHicap, gatewayKimiCode, gatewayLmstudio, gatewayMistral, gatewayNvidiaNim, gatewayOllama, gatewayOpencodeGo, gatewayOpencode, gatewayOpenrouter, gatewayTogether, gatewayVertex, gatewayXiaomiMimoToken] as const satisfies readonly GatewayDescriptor[]
|
||||
export const ANTHROPIC_PROXY_DESCRIPTORS = [] as const satisfies readonly AnthropicProxyDescriptor[]
|
||||
export const BRAND_DESCRIPTORS = [brandClaude, brandDeepseek, brandFireworks, brandGemini, brandGlm, brandGpt, brandKimi, brandLlama, brandMinimax, brandMistral, brandNearai, brandNemotron, brandOpenaiCompatibleAlias, brandQwen, brandXai, brandXiaomiMimo] as const satisfies readonly BrandDescriptor[]
|
||||
export const MODEL_DESCRIPTOR_GROUPS = [modelClaude, modelDeepseek, modelFireworksMerged, modelGemini, modelGlm, modelGpt, modelKimi, modelLlama, modelMinimax, modelMistral, modelNearai, modelNemotron, modelOpenaiCompatibleAlias, modelOpencode, modelQwen, modelXai, modelXiaomiMimo] as const satisfies readonly (readonly ModelDescriptor[])[]
|
||||
|
||||
@@ -112,6 +112,21 @@ export const PROVIDER_PRESET_MANIFEST = [
|
||||
"OPENAI_MODEL"
|
||||
]
|
||||
},
|
||||
{
|
||||
"preset": "clinepass",
|
||||
"routeKind": "gateway",
|
||||
"routeId": "clinepass",
|
||||
"vendorId": "openai",
|
||||
"gatewayId": "clinepass",
|
||||
"description": "ClinePass - AI model gateway with usage limits",
|
||||
"apiKeyEnvVars": [
|
||||
"CLINE_API_KEY"
|
||||
],
|
||||
"modelEnvVars": [
|
||||
"CLINE_API_MODEL",
|
||||
"OPENAI_MODEL"
|
||||
]
|
||||
},
|
||||
{
|
||||
"preset": "deepseek",
|
||||
"routeKind": "vendor",
|
||||
@@ -444,6 +459,7 @@ export const ORDERED_PROVIDER_PRESETS = [
|
||||
"atlas-cloud",
|
||||
"azure-openai",
|
||||
"bankr",
|
||||
"clinepass",
|
||||
"deepseek",
|
||||
"fireworks",
|
||||
"gemini",
|
||||
|
||||
@@ -206,6 +206,23 @@ test('resolveActiveRouteIdFromEnv treats xAI credential-only env as xAI', () =>
|
||||
).toBe('xai')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv treats ClinePass credential-only env as ClinePass', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
CLINE_API_KEY: 'cline-key',
|
||||
}),
|
||||
).toBe('clinepass')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv prefers ClinePass key over Fireworks env-only intent', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
CLINE_API_KEY: 'cline-key',
|
||||
FIREWORKS_API_KEY: 'fw-key',
|
||||
}),
|
||||
).toBe('clinepass')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv prefers xAI when env-only keys compete', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
@@ -285,6 +302,102 @@ test.each([
|
||||
},
|
||||
)
|
||||
|
||||
test('resolveActiveRouteIdFromEnv refines generic OpenAI profile by ClinePass base URL', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv(
|
||||
{
|
||||
CLAUDE_CODE_USE_OPENAI: '1',
|
||||
OPENAI_BASE_URL: 'https://api.cline.bot/api/v1',
|
||||
OPENAI_MODEL: 'cline-pass/deepseek-v4-flash',
|
||||
},
|
||||
{ activeProfileProvider: 'openai' },
|
||||
),
|
||||
).toBe('clinepass')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv resolves ClinePass profile provider without env applied flag', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv(
|
||||
{},
|
||||
{ activeProfileProvider: 'clinepass' },
|
||||
),
|
||||
).toBe('clinepass')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv resolves ClinePass profile provider without CLAUDE_CODE_USE_OPENAI', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv(
|
||||
{
|
||||
CLINE_API_KEY: 'cp-key',
|
||||
},
|
||||
{ activeProfileProvider: 'clinepass' },
|
||||
),
|
||||
).toBe('clinepass')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv still returns anthropic when no env flags and no profile provider', () => {
|
||||
expect(resolveActiveRouteIdFromEnv({})).toBe('anthropic')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv resolves Atlas Cloud profile provider without env applied flag', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv(
|
||||
{},
|
||||
{ activeProfileProvider: 'atlas-cloud' },
|
||||
),
|
||||
).toBe('atlas-cloud')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv does not resolve custom profile provider as a known route', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv(
|
||||
{},
|
||||
{ activeProfileProvider: 'custom' },
|
||||
),
|
||||
).toBe('anthropic')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv resolves custom profile provider via ClinePass base URL', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv(
|
||||
{},
|
||||
{
|
||||
activeProfileProvider: 'custom',
|
||||
activeProfileBaseUrl: 'https://api.cline.bot/api/v1',
|
||||
},
|
||||
),
|
||||
).toBe('clinepass')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv resolves openai profile provider via ClinePass base URL', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv(
|
||||
{
|
||||
CLAUDE_CODE_USE_OPENAI: '1',
|
||||
},
|
||||
{
|
||||
activeProfileProvider: 'openai',
|
||||
activeProfileBaseUrl: 'https://api.cline.bot/api/v1',
|
||||
},
|
||||
),
|
||||
).toBe('clinepass')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv lets explicit OPENAI_BASE_URL override saved ClinePass profile', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv(
|
||||
{
|
||||
CLAUDE_CODE_USE_OPENAI: '1',
|
||||
OPENAI_BASE_URL: 'https://openrouter.ai/api/v1',
|
||||
},
|
||||
{
|
||||
activeProfileProvider: 'clinepass',
|
||||
activeProfileBaseUrl: 'https://api.cline.bot/api/v1',
|
||||
},
|
||||
),
|
||||
).toBe('openrouter')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv does not infer MiniMax with OpenAI credentials', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
|
||||
@@ -361,6 +361,35 @@ export function isFireworksBaseUrl(value: string | undefined): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function isClinePassBaseUrl(value: string | undefined): boolean {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(trimmed).hostname.toLowerCase() === 'api.cline.bot'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getClinePassBaseUrlOverride(
|
||||
processEnv: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
const openAIBaseUrl = processEnv.OPENAI_BASE_URL?.trim()
|
||||
if (isClinePassBaseUrl(openAIBaseUrl)) {
|
||||
return openAIBaseUrl
|
||||
}
|
||||
|
||||
const openAIApiBase = processEnv.OPENAI_API_BASE?.trim()
|
||||
if (isClinePassBaseUrl(openAIApiBase)) {
|
||||
return openAIApiBase
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getNearaiBaseUrlOverride(
|
||||
processEnv: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
@@ -482,6 +511,7 @@ export function hasXaiEnvOnlyProviderIntent(
|
||||
): boolean {
|
||||
return (
|
||||
hasNonEmptyEnvValue(processEnv.XAI_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.CLINE_API_KEY) &&
|
||||
!hasConflictingOpenAIBaseUrlForRoute(processEnv, isXaiBaseUrl) &&
|
||||
hasNoExplicitNonOpenAICompatibleProvider(processEnv)
|
||||
)
|
||||
@@ -502,6 +532,7 @@ export function hasMiniMaxEnvOnlyProviderIntent(
|
||||
(hasExplicitMiniMaxIntent ||
|
||||
(!hasAnyUsableOpenAICredential(processEnv) &&
|
||||
!hasNonEmptyEnvValue(processEnv.XAI_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.CLINE_API_KEY) &&
|
||||
hasNoExplicitNonOpenAICompatibleProvider(processEnv)))
|
||||
)
|
||||
}
|
||||
@@ -514,6 +545,7 @@ export function hasVeniceEnvOnlyProviderIntent(
|
||||
!hasAnyUsableOpenAICredential(processEnv) &&
|
||||
!hasNonEmptyEnvValue(processEnv.XAI_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.MINIMAX_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.CLINE_API_KEY) &&
|
||||
!hasConflictingOpenAIBaseUrlForRoute(processEnv, isVeniceBaseUrl) &&
|
||||
hasNoExplicitNonOpenAICompatibleProvider(processEnv)
|
||||
)
|
||||
@@ -528,6 +560,7 @@ export function hasXiaomiMimoEnvOnlyProviderIntent(
|
||||
!hasNonEmptyEnvValue(processEnv.XAI_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.MINIMAX_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.VENICE_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.CLINE_API_KEY) &&
|
||||
!hasConflictingOpenAIBaseUrlForRoute(processEnv, isXiaomiMimoBaseUrl) &&
|
||||
hasNoExplicitNonOpenAICompatibleProvider(processEnv)
|
||||
)
|
||||
@@ -543,6 +576,7 @@ export function hasNearaiEnvOnlyProviderIntent(
|
||||
!hasNonEmptyEnvValue(processEnv.VENICE_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.MIMO_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.FIREWORKS_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.CLINE_API_KEY) &&
|
||||
!hasConflictingOpenAIBaseUrlForRoute(processEnv, isNearaiBaseUrl) &&
|
||||
hasNoExplicitNonOpenAICompatibleProvider(processEnv)
|
||||
)
|
||||
@@ -563,14 +597,30 @@ export function hasFireworksEnvOnlyProviderIntent(
|
||||
!hasNonEmptyEnvValue(processEnv.VENICE_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.MIMO_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.NEARAI_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.CLINE_API_KEY) &&
|
||||
!hasConflictingOpenAIBaseUrlForRoute(processEnv, isFireworksBaseUrl) &&
|
||||
hasNoExplicitNonOpenAICompatibleProvider(processEnv)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether the process environment is configured to route traffic
|
||||
* exclusively through ClinePass based on the presence of CLINE_API_KEY
|
||||
* and the absence of conflicting env vars for other providers.
|
||||
*/
|
||||
export function hasClinePassEnvOnlyProviderIntent(
|
||||
processEnv: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return (
|
||||
hasNonEmptyEnvValue(processEnv.CLINE_API_KEY) &&
|
||||
!hasConflictingOpenAIBaseUrlForRoute(processEnv, isClinePassBaseUrl) &&
|
||||
hasNoExplicitNonOpenAICompatibleProvider(processEnv)
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveEnvOnlyProviderRouteId(
|
||||
processEnv: NodeJS.ProcessEnv = process.env,
|
||||
): 'xai' | 'minimax' | 'venice' | 'xiaomi-mimo' | 'nearai' | 'fireworks' | null {
|
||||
): 'xai' | 'minimax' | 'venice' | 'xiaomi-mimo' | 'nearai' | 'fireworks' | 'clinepass' | null {
|
||||
if (
|
||||
hasMiniMaxRouteIntent(processEnv) &&
|
||||
hasMiniMaxEnvOnlyProviderIntent(processEnv)
|
||||
@@ -602,6 +652,10 @@ export function resolveEnvOnlyProviderRouteId(
|
||||
return 'fireworks'
|
||||
}
|
||||
|
||||
if (hasClinePassEnvOnlyProviderIntent(processEnv)) {
|
||||
return 'clinepass'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -794,6 +848,7 @@ export function resolveActiveRouteIdFromEnv(
|
||||
processEnv: NodeJS.ProcessEnv = process.env,
|
||||
options?: {
|
||||
activeProfileProvider?: string
|
||||
activeProfileBaseUrl?: string
|
||||
},
|
||||
): string | null {
|
||||
if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI)) {
|
||||
@@ -820,10 +875,11 @@ export function resolveActiveRouteIdFromEnv(
|
||||
processEnv.OPENAI_BASE_URL ?? processEnv.OPENAI_API_BASE
|
||||
const matchedRoute = resolveRouteIdFromBaseUrl(baseUrl)
|
||||
|
||||
if (
|
||||
processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED === '1' &&
|
||||
options?.activeProfileProvider
|
||||
) {
|
||||
if (matchedRoute) {
|
||||
return matchedRoute
|
||||
}
|
||||
|
||||
if (options?.activeProfileProvider) {
|
||||
const route = resolveProfileRoute(options.activeProfileProvider)
|
||||
if (
|
||||
route.routeId !== 'unknown-fallback' &&
|
||||
@@ -832,10 +888,14 @@ export function resolveActiveRouteIdFromEnv(
|
||||
) {
|
||||
return route.routeId
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedRoute) {
|
||||
return matchedRoute
|
||||
// A custom/unknown profile may still target a known gateway via its
|
||||
// saved base URL; prefer that route over the generic openai/custom path.
|
||||
const profileBaseUrlRoute = resolveRouteIdFromBaseUrl(
|
||||
options.activeProfileBaseUrl,
|
||||
)
|
||||
if (profileBaseUrlRoute) {
|
||||
return profileBaseUrlRoute
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedBaseUrl = normalizeComparableBaseUrl(baseUrl)
|
||||
@@ -850,6 +910,25 @@ export function resolveActiveRouteIdFromEnv(
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
if (options?.activeProfileProvider) {
|
||||
const route = resolveProfileRoute(options.activeProfileProvider)
|
||||
if (
|
||||
route.routeId !== 'unknown-fallback' &&
|
||||
route.routeId !== 'openai' &&
|
||||
route.routeId !== 'custom'
|
||||
) {
|
||||
return route.routeId
|
||||
}
|
||||
// A custom/unknown profile may still target a known gateway via its
|
||||
// saved base URL; prefer that route over the generic anthropic fallback.
|
||||
const profileBaseUrlRoute = resolveRouteIdFromBaseUrl(
|
||||
options.activeProfileBaseUrl,
|
||||
)
|
||||
if (profileBaseUrlRoute) {
|
||||
return profileBaseUrlRoute
|
||||
}
|
||||
}
|
||||
|
||||
return 'anthropic'
|
||||
}
|
||||
|
||||
|
||||
@@ -245,6 +245,7 @@ export function resolveOpenAIShimRuntimeContext(options?: {
|
||||
|
||||
const activeRouteId = resolveActiveRouteIdFromEnv(runtimeEnv, {
|
||||
activeProfileProvider: options?.activeProfileProvider,
|
||||
activeProfileBaseUrl: options?.baseUrl,
|
||||
})
|
||||
const baseUrlRouteId = resolveRouteIdFromBaseUrl(options?.baseUrl)
|
||||
const routeId =
|
||||
@@ -438,7 +439,8 @@ export function resolveModelRuntimeLimits(options: {
|
||||
}
|
||||
|
||||
const routeId = resolveActiveRouteIdFromEnv(runtimeEnv, {
|
||||
activeProfileProvider: options.activeProfileProvider,
|
||||
activeProfileProvider: options?.activeProfileProvider,
|
||||
activeProfileBaseUrl: options?.baseUrl,
|
||||
})
|
||||
const modelApiName = getBaseModelApiName(options.model) ?? options.model
|
||||
const catalogEntry = findCatalogEntryForApiName(routeId, modelApiName)
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import {
|
||||
buildClinePassUsageRows,
|
||||
fetchClinePassUsage,
|
||||
normalizeClinePassUsagePayload,
|
||||
} from './clinepassUsage.js'
|
||||
import { getClinePassUsageUrl } from './clinepassUsage/fetch.js'
|
||||
|
||||
describe('normalizeClinePassUsagePayload', () => {
|
||||
test('normalizes usage limits from captured response shape', () => {
|
||||
const normalized = normalizeClinePassUsagePayload({
|
||||
data: {
|
||||
limits: [
|
||||
{ type: 'five_hour', percentUsed: 86, resetsAt: '2026-06-29T22:00:00.000Z' },
|
||||
{ type: 'weekly', percentUsed: 34, resetsAt: '2026-07-07T00:00:00.000Z' },
|
||||
{ type: 'monthly', percentUsed: 17, resetsAt: '2026-07-29T00:00:00.000Z' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(normalized).toEqual({
|
||||
availability: 'available',
|
||||
planName: undefined,
|
||||
windows: [
|
||||
{ label: '5-Hour Limit', type: 'five_hour', usedPercent: 86, resetsAt: '2026-06-29T22:00:00.000Z' },
|
||||
{ label: 'Weekly Limit', type: 'weekly', usedPercent: 34, resetsAt: '2026-07-07T00:00:00.000Z' },
|
||||
{ label: 'Monthly Limit', type: 'monthly', usedPercent: 17, resetsAt: '2026-07-29T00:00:00.000Z' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('clamps percent values to [0, 100]', () => {
|
||||
const normalized = normalizeClinePassUsagePayload({
|
||||
data: {
|
||||
limits: [
|
||||
{ type: 'five_hour', percentUsed: -5, resetsAt: '2026-06-29T22:00:00.000Z' },
|
||||
{ type: 'weekly', percentUsed: 150, resetsAt: '2026-07-07T00:00:00.000Z' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(normalized).toEqual({
|
||||
availability: 'available',
|
||||
planName: undefined,
|
||||
windows: [
|
||||
{ label: '5-Hour Limit', type: 'five_hour', usedPercent: 0, resetsAt: '2026-06-29T22:00:00.000Z' },
|
||||
{ label: 'Weekly Limit', type: 'weekly', usedPercent: 100, resetsAt: '2026-07-07T00:00:00.000Z' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('rounds fractional percent values', () => {
|
||||
const normalized = normalizeClinePassUsagePayload({
|
||||
data: {
|
||||
limits: [{ type: 'five_hour', percentUsed: 86.7, resetsAt: '2026-06-29T22:00:00.000Z' }],
|
||||
},
|
||||
})
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
availability: 'available',
|
||||
windows: [{ label: '5-Hour Limit', usedPercent: 87 }],
|
||||
})
|
||||
})
|
||||
|
||||
test('returns unknown availability when no limits are present', () => {
|
||||
const normalized = normalizeClinePassUsagePayload({
|
||||
data: { limits: [] },
|
||||
})
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
availability: 'unknown',
|
||||
windows: [],
|
||||
})
|
||||
})
|
||||
|
||||
test('returns unknown availability for non-record payload', () => {
|
||||
const normalized = normalizeClinePassUsagePayload(null)
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
availability: 'unknown',
|
||||
windows: [],
|
||||
})
|
||||
})
|
||||
|
||||
test('drops invalid resetsAt values instead of echoing them', () => {
|
||||
const normalized = normalizeClinePassUsagePayload({
|
||||
data: {
|
||||
limits: [{ type: 'five_hour', percentUsed: 10, resetsAt: 'not-a-date' }],
|
||||
},
|
||||
})
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
availability: 'available',
|
||||
windows: [{ label: '5-Hour Limit', usedPercent: 10, resetsAt: undefined }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildClinePassUsageRows', () => {
|
||||
test('builds window rows from normalized windows', () => {
|
||||
const rows = buildClinePassUsageRows([
|
||||
{ label: '5-Hour Limit', type: 'five_hour', usedPercent: 86, resetsAt: '2026-06-29T22:00:00.000Z' },
|
||||
{ label: 'Weekly Limit', type: 'weekly', usedPercent: 34 },
|
||||
])
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ kind: 'window', label: '5-Hour Limit', usedPercent: 86, resetsAt: '2026-06-29T22:00:00.000Z' },
|
||||
{ kind: 'window', label: 'Weekly Limit', usedPercent: 34 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClinePass usage helpers', () => {
|
||||
test('getClinePassUsageUrl returns the canonical usage endpoint', () => {
|
||||
expect(getClinePassUsageUrl()).toBe('https://api.cline.bot/api/v1/users/me/plan/usage-limits')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchClinePassUsage', () => {
|
||||
const originalEnv = { ...process.env }
|
||||
let originalFetch: typeof globalThis.fetch
|
||||
const originalMacro = (globalThis as unknown as Record<string, unknown>).MACRO
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.CLINE_API_KEY = 'cp-test-key'
|
||||
originalFetch = globalThis.fetch
|
||||
;(globalThis as unknown as Record<string, unknown>).MACRO = { VERSION: 'test' }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
globalThis.fetch = originalFetch
|
||||
;(globalThis as unknown as Record<string, unknown>).MACRO = originalMacro
|
||||
process.env = { ...originalEnv }
|
||||
})
|
||||
|
||||
test('throws when CLINE_API_KEY is missing', async () => {
|
||||
delete process.env.CLINE_API_KEY
|
||||
await expect(fetchClinePassUsage()).rejects.toThrow(
|
||||
'ClinePass auth is required. Set CLINE_API_KEY.',
|
||||
)
|
||||
})
|
||||
|
||||
test('sends Authorization bearer header and returns normalized usage', async () => {
|
||||
const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
limits: [
|
||||
{ type: 'five_hour', percentUsed: 25, resetsAt: '2026-06-29T22:00:00.000Z' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
)
|
||||
})
|
||||
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch
|
||||
|
||||
const result = await fetchClinePassUsage()
|
||||
|
||||
expect(result.availability).toBe('available')
|
||||
expect(result.windows).toEqual([
|
||||
{ label: '5-Hour Limit', type: 'five_hour', usedPercent: 25, resetsAt: '2026-06-29T22:00:00.000Z' },
|
||||
])
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe(getClinePassUsageUrl())
|
||||
expect(init?.method).toBe('GET')
|
||||
expect((init?.headers as Record<string, string>)?.Authorization).toBe('Bearer cp-test-key')
|
||||
})
|
||||
|
||||
test('returns unknown availability on non-OK response', async () => {
|
||||
globalThis.fetch = mock(async () => {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
|
||||
const result = await fetchClinePassUsage()
|
||||
|
||||
expect(result.availability).toBe('unknown')
|
||||
expect(result.windows).toEqual([])
|
||||
})
|
||||
|
||||
test('propagates network errors', async () => {
|
||||
globalThis.fetch = mock(async () => {
|
||||
throw new Error('net failure')
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
|
||||
await expect(fetchClinePassUsage()).rejects.toThrow('net failure')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
export type {
|
||||
ClinePassUsageData,
|
||||
ClinePassUsageRow,
|
||||
ClinePassUsageWindow,
|
||||
} from './clinepassUsage/types.js'
|
||||
|
||||
export {
|
||||
buildClinePassUsageRows,
|
||||
normalizeClinePassUsagePayload,
|
||||
} from './clinepassUsage/parse.js'
|
||||
|
||||
export {
|
||||
fetchClinePassUsage,
|
||||
getClinePassUsageUrl,
|
||||
resolveClinePassUsageBaseUrl,
|
||||
} from './clinepassUsage/fetch.js'
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createCombinedAbortSignal } from '../../../utils/combinedAbortSignal.js'
|
||||
import { logForDebugging } from '../../../utils/debug.js'
|
||||
import { getClaudeCodeUserAgent } from '../../../utils/userAgent.js'
|
||||
import {
|
||||
DEFAULT_CLINEPASS_BASE_URL,
|
||||
DEFAULT_CLINEPASS_UNAVAILABLE_MESSAGE,
|
||||
type ClinePassUsageData,
|
||||
} from './types.js'
|
||||
import { normalizeClinePassUsagePayload } from './parse.js'
|
||||
|
||||
function trimTrailingSlash(value: string): string {
|
||||
return value.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
export function resolveClinePassUsageBaseUrl(): string {
|
||||
return DEFAULT_CLINEPASS_BASE_URL
|
||||
}
|
||||
|
||||
function buildUnavailableResult(message: string): ClinePassUsageData {
|
||||
return {
|
||||
availability: 'unknown',
|
||||
windows: [],
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
export function getClinePassUsageUrl(): string {
|
||||
const base = trimTrailingSlash(DEFAULT_CLINEPASS_BASE_URL)
|
||||
return `${base}/api/v1/users/me/plan/usage-limits`
|
||||
}
|
||||
|
||||
export async function fetchClinePassUsage(): Promise<ClinePassUsageData> {
|
||||
const apiKey = process.env.CLINE_API_KEY
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'ClinePass auth is required. Set CLINE_API_KEY.',
|
||||
)
|
||||
}
|
||||
|
||||
const usageUrl = getClinePassUsageUrl()
|
||||
let response: Response
|
||||
|
||||
const { signal, cleanup } = createCombinedAbortSignal(undefined, {
|
||||
timeoutMs: 10000,
|
||||
})
|
||||
try {
|
||||
try {
|
||||
response = await fetch(usageUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': getClaudeCodeUserAgent(),
|
||||
},
|
||||
signal,
|
||||
})
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`[clinepass] usage request failed for ${usageUrl}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
throw error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text().catch(() => '')
|
||||
logForDebugging(
|
||||
`[clinepass] usage endpoint returned status ${response.status}: ${errorBody}`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
return buildUnavailableResult(DEFAULT_CLINEPASS_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
|
||||
const normalized = normalizeClinePassUsagePayload(await response.json())
|
||||
return normalized
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
DEFAULT_CLINEPASS_UNAVAILABLE_MESSAGE,
|
||||
type ClinePassUsageData,
|
||||
type ClinePassUsageRow,
|
||||
type ClinePassUsageWindow,
|
||||
} from './types.js'
|
||||
|
||||
type RecordLike = Record<string, unknown>
|
||||
|
||||
function isRecord(value: unknown): value is RecordLike {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number.parseFloat(value)
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round(value)))
|
||||
}
|
||||
|
||||
function toIsoDate(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Date.parse(value)
|
||||
return Number.isNaN(parsed) ? undefined : new Date(parsed).toISOString()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const WINDOW_TYPE_LABELS: Record<string, string> = {
|
||||
five_hour: '5-Hour Limit',
|
||||
weekly: 'Weekly Limit',
|
||||
monthly: 'Monthly Limit',
|
||||
}
|
||||
|
||||
function normalizeWindow(raw: unknown): ClinePassUsageWindow | undefined {
|
||||
if (!isRecord(raw)) return undefined
|
||||
|
||||
const type = asString(raw.type) ?? 'unknown'
|
||||
const percentUsed = asNumber(raw.percentUsed)
|
||||
if (percentUsed === undefined) return undefined
|
||||
|
||||
const label = WINDOW_TYPE_LABELS[type] ?? type
|
||||
const resetsAt = toIsoDate(raw.resetsAt)
|
||||
|
||||
return {
|
||||
label,
|
||||
type,
|
||||
usedPercent: clampPercent(percentUsed),
|
||||
resetsAt,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLimitsArray(limits: unknown): ClinePassUsageWindow[] {
|
||||
if (!Array.isArray(limits)) return []
|
||||
return limits
|
||||
.map(normalizeWindow)
|
||||
.filter((w): w is ClinePassUsageWindow => w !== undefined)
|
||||
}
|
||||
|
||||
export function normalizeClinePassUsagePayload(
|
||||
payload: unknown,
|
||||
): ClinePassUsageData {
|
||||
if (!isRecord(payload)) {
|
||||
return {
|
||||
availability: 'unknown',
|
||||
windows: [],
|
||||
message: DEFAULT_CLINEPASS_UNAVAILABLE_MESSAGE,
|
||||
}
|
||||
}
|
||||
|
||||
const data = isRecord(payload.data) ? payload.data : payload
|
||||
const limits =
|
||||
isRecord(data) && Array.isArray(data.limits)
|
||||
? data.limits
|
||||
: []
|
||||
|
||||
const windows = normalizeLimitsArray(limits)
|
||||
|
||||
if (windows.length === 0) {
|
||||
return {
|
||||
availability: 'unknown',
|
||||
windows: [],
|
||||
message: DEFAULT_CLINEPASS_UNAVAILABLE_MESSAGE,
|
||||
}
|
||||
}
|
||||
|
||||
const planName = asString(
|
||||
payload.planName ??
|
||||
payload.plan_name ??
|
||||
data.planName ??
|
||||
data.plan_name,
|
||||
)
|
||||
|
||||
return {
|
||||
availability: 'available',
|
||||
planName,
|
||||
windows,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildClinePassUsageRows(
|
||||
windows: ClinePassUsageWindow[],
|
||||
): ClinePassUsageRow[] {
|
||||
const rows: ClinePassUsageRow[] = []
|
||||
|
||||
for (const window of windows) {
|
||||
rows.push({
|
||||
kind: 'window',
|
||||
label: window.label,
|
||||
usedPercent: window.usedPercent,
|
||||
resetsAt: window.resetsAt,
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type ClinePassUsageWindow = {
|
||||
label: string
|
||||
type: string
|
||||
usedPercent: number
|
||||
resetsAt?: string
|
||||
}
|
||||
|
||||
export type ClinePassUsageData =
|
||||
| {
|
||||
availability: 'available'
|
||||
planName?: string
|
||||
windows: ClinePassUsageWindow[]
|
||||
}
|
||||
| {
|
||||
availability: 'unknown'
|
||||
planName?: string
|
||||
windows: ClinePassUsageWindow[]
|
||||
message: string
|
||||
}
|
||||
|
||||
export type ClinePassUsageRow = {
|
||||
kind: 'window'
|
||||
label: string
|
||||
usedPercent: number
|
||||
resetsAt?: string
|
||||
}
|
||||
|
||||
export const DEFAULT_CLINEPASS_BASE_URL = 'https://api.cline.bot'
|
||||
export const DEFAULT_CLINEPASS_UNAVAILABLE_MESSAGE =
|
||||
'Usage details are not available for this ClinePass account.'
|
||||
@@ -102,3 +102,116 @@ test('resolveProviderRequest leaves Hicap routing untouched without explicit ali
|
||||
expect(request.resolvedModel).toBe('claude-opus-4-7')
|
||||
expect(request.baseUrl).toBe('https://api.hicap.ai/v1')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest uses CLINE_API_MODEL when CLINE_API_KEY is present', () => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
CLINE_API_KEY: 'cp-key',
|
||||
CLINE_API_MODEL: 'cline-pass/qwen3.7-max',
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('cline-pass/qwen3.7-max')
|
||||
expect(request.baseUrl).toBe('https://api.cline.bot/api/v1')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest falls back to OPENAI_MODEL for ClinePass when CLINE_API_MODEL is unset', () => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
CLINE_API_KEY: 'cp-key',
|
||||
OPENAI_MODEL: 'cline-pass/deepseek-v4-flash',
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('cline-pass/deepseek-v4-flash')
|
||||
expect(request.baseUrl).toBe('https://api.cline.bot/api/v1')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest treats blank CLINE_API_MODEL as unset for ClinePass', () => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
CLINE_API_KEY: 'cp-key',
|
||||
CLINE_API_MODEL: ' ',
|
||||
OPENAI_MODEL: 'cline-pass/qwen3.7-max',
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('cline-pass/qwen3.7-max')
|
||||
expect(request.baseUrl).toBe('https://api.cline.bot/api/v1')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest uses the ClinePass route default when no model env is set', () => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
CLINE_API_KEY: 'cp-key',
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('cline-pass/deepseek-v4-flash')
|
||||
expect(request.baseUrl).toBe('https://api.cline.bot/api/v1')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest ignores CLINE_API_MODEL without CLINE_API_KEY', () => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
CLINE_API_MODEL: 'cline-pass/qwen3.7-max',
|
||||
OPENAI_API_KEY: 'openai-key',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('gpt-4o')
|
||||
expect(request.baseUrl).toBe('https://api.openai.com/v1')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest ignores ClinePass model when GitHub mode is active', () => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
CLAUDE_CODE_USE_GITHUB: '1',
|
||||
CLINE_API_KEY: 'cp-key',
|
||||
CLINE_API_MODEL: 'cline-pass/qwen3.7-max',
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('github:copilot')
|
||||
expect(request.baseUrl).not.toContain('cline.bot')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest ignores ClinePass model when explicit OPENAI_BASE_URL points elsewhere', () => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
CLINE_API_KEY: 'cp-key',
|
||||
CLINE_API_MODEL: 'cline-pass/qwen3.7-max',
|
||||
OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('gpt-4o')
|
||||
expect(request.baseUrl).toBe('https://api.openai.com/v1')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest ignores ClinePass model when explicit baseUrl option points elsewhere', () => {
|
||||
const request = resolveProviderRequest({
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
processEnv: {
|
||||
CLINE_API_KEY: 'cp-key',
|
||||
CLINE_API_MODEL: 'cline-pass/qwen3.7-max',
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('codexplan')
|
||||
expect(request.baseUrl).toBe('https://openrouter.ai/api/v1')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest uses ClinePass model when no explicit base URL is set', () => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
CLINE_API_KEY: 'cp-key',
|
||||
CLINE_API_MODEL: 'cline-pass/qwen3.7-max',
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('cline-pass/qwen3.7-max')
|
||||
expect(request.baseUrl).toBe('https://api.cline.bot/api/v1')
|
||||
})
|
||||
|
||||
@@ -19,7 +19,14 @@ import {
|
||||
DEFAULT_GEMINI_BASE_URL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
} from 'src/utils/providerProfile.js'
|
||||
import {
|
||||
DEFAULT_CLINEPASS_BASE_URL,
|
||||
} from './clinepassUsage/types.js'
|
||||
import { getCatalogEntriesForRoute } from '../../integrations/registry.js'
|
||||
import {
|
||||
getRouteDefaultModel,
|
||||
isClinePassBaseUrl,
|
||||
} from '../../integrations/routeMetadata.js'
|
||||
import {
|
||||
openAIShimSupportsApiFormatForModel,
|
||||
resolveOpenAIShimRuntimeContext,
|
||||
@@ -30,6 +37,7 @@ export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'
|
||||
export const DEFAULT_MISTRAL_BASE_URL = 'https://api.mistral.ai/v1'
|
||||
export const DEFAULT_OPENCODE_BASE_URL = 'https://opencode.ai/zen/v1'
|
||||
export const DEFAULT_OPENCODE_GO_BASE_URL = 'https://opencode.ai/zen/go/v1'
|
||||
export const DEFAULT_CLINEPASS_API_BASE_URL = `${DEFAULT_CLINEPASS_BASE_URL}/api/v1`
|
||||
/** Default GitHub Copilot API model when user selects copilot / github:copilot */
|
||||
export const DEFAULT_GITHUB_MODELS_API_MODEL = 'gpt-4o'
|
||||
const warnedUndefinedEnvNames = new Set<string>()
|
||||
@@ -797,18 +805,7 @@ export function resolveProviderRequest(options?: {
|
||||
const isGithubMode = isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB)
|
||||
const isMistralMode = isEnvTruthy(processEnv.CLAUDE_CODE_USE_MISTRAL)
|
||||
const isGeminiMode = isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI)
|
||||
const requestedModel =
|
||||
options?.model?.trim() ||
|
||||
(isMistralMode
|
||||
? processEnv.MISTRAL_MODEL?.trim()
|
||||
: processEnv.OPENAI_MODEL?.trim()) ||
|
||||
(isGeminiMode
|
||||
? processEnv.GEMINI_MODEL?.trim()
|
||||
: processEnv.OPENAI_MODEL?.trim()) ||
|
||||
options?.fallbackModel?.trim() ||
|
||||
(isGeminiMode ? DEFAULT_GEMINI_MODEL : undefined) ||
|
||||
(isGithubMode ? 'github:copilot' : 'codexplan')
|
||||
const descriptor = parseModelDescriptor(requestedModel)
|
||||
const isClinePassMode = Boolean(processEnv.CLINE_API_KEY?.trim())
|
||||
const explicitBaseUrl = asEnvUrl(options?.baseUrl)
|
||||
|
||||
const normalizedMistralEnvBaseUrl = asNamedEnvUrl(
|
||||
@@ -842,10 +839,41 @@ export function resolveProviderRequest(options?: {
|
||||
? asNamedEnvUrl(processEnv.OPENAI_API_BASE, 'OPENAI_API_BASE')
|
||||
: undefined)
|
||||
|
||||
// ClinePass model selection is only valid when no concrete non-ClinePass
|
||||
// base URL is explicitly provided via options or env. This prevents stale
|
||||
// CLINE_API_KEY/CLINE_API_MODEL from overriding an explicit OPENAI_BASE_URL
|
||||
// pointing at a different provider.
|
||||
const concreteBaseUrlBeforeDefault =
|
||||
explicitBaseUrl ?? primaryEnvBaseUrl ?? fallbackEnvBaseUrl
|
||||
const hasConcreteNonClinePassBaseUrl =
|
||||
Boolean(concreteBaseUrlBeforeDefault) && !isClinePassBaseUrl(concreteBaseUrlBeforeDefault)
|
||||
const effectiveClinePassMode =
|
||||
isClinePassMode && !isGithubMode && !hasConcreteNonClinePassBaseUrl
|
||||
const clinePassDefaultModel = effectiveClinePassMode
|
||||
? getRouteDefaultModel('clinepass')
|
||||
: undefined
|
||||
|
||||
const requestedModel =
|
||||
options?.model?.trim() ||
|
||||
(isMistralMode
|
||||
? processEnv.MISTRAL_MODEL?.trim()
|
||||
: isGeminiMode
|
||||
? processEnv.GEMINI_MODEL?.trim()
|
||||
: effectiveClinePassMode
|
||||
? processEnv.CLINE_API_MODEL?.trim() ||
|
||||
processEnv.OPENAI_MODEL?.trim()
|
||||
: processEnv.OPENAI_MODEL?.trim()) ||
|
||||
options?.fallbackModel?.trim() ||
|
||||
(isGeminiMode ? DEFAULT_GEMINI_MODEL : undefined) ||
|
||||
clinePassDefaultModel ||
|
||||
(isGithubMode ? 'github:copilot' : 'codexplan')
|
||||
const descriptor = parseModelDescriptor(requestedModel)
|
||||
|
||||
const envBaseUrlRaw =
|
||||
explicitBaseUrl ??
|
||||
primaryEnvBaseUrl ??
|
||||
fallbackEnvBaseUrl
|
||||
fallbackEnvBaseUrl ??
|
||||
(effectiveClinePassMode ? DEFAULT_CLINEPASS_API_BASE_URL : undefined)
|
||||
|
||||
const githubEnterpriseEnvUrl = asGithubEnterpriseEnvUrl(
|
||||
processEnv.GITHUB_ENTERPRISE_URL,
|
||||
|
||||
@@ -106,6 +106,7 @@ const PROFILE_ENV_KEYS = [
|
||||
'ATLAS_CLOUD_API_KEY',
|
||||
'NEARAI_API_KEY',
|
||||
'FIREWORKS_API_KEY',
|
||||
'CLINE_API_KEY',
|
||||
'OPENCODE_API_KEY',
|
||||
DEFAULT_STARTUP_PROVIDER_ENV_VAR,
|
||||
] as const
|
||||
@@ -136,6 +137,7 @@ export type ProviderProfile =
|
||||
| 'vertex'
|
||||
| 'xai'
|
||||
| 'opencode'
|
||||
| 'clinepass'
|
||||
|
||||
export type ProfileEnv = {
|
||||
ANTHROPIC_BASE_URL?: string
|
||||
@@ -181,6 +183,7 @@ export type ProfileEnv = {
|
||||
VENICE_API_KEY?: string
|
||||
MIMO_API_KEY?: string
|
||||
ATLAS_CLOUD_API_KEY?: string
|
||||
CLINE_API_KEY?: string
|
||||
NEARAI_API_KEY?: string
|
||||
FIREWORKS_API_KEY?: string
|
||||
OPENCODE_API_KEY?: string
|
||||
|
||||
@@ -65,6 +65,7 @@ const RESTORED_KEYS = [
|
||||
'VENICE_API_KEY',
|
||||
'MIMO_API_KEY',
|
||||
'ATLAS_CLOUD_API_KEY',
|
||||
'CLINE_API_KEY',
|
||||
'HICAP_API_KEY',
|
||||
'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS',
|
||||
] as const
|
||||
@@ -257,6 +258,17 @@ function buildAtlasCloudProfile(overrides: Partial<ProviderProfile> = {}): Provi
|
||||
})
|
||||
}
|
||||
|
||||
function buildClinePassProfile(overrides: Partial<ProviderProfile> = {}): ProviderProfile {
|
||||
return buildProfile({
|
||||
provider: 'clinepass',
|
||||
name: 'ClinePass',
|
||||
baseUrl: 'https://api.cline.bot/api/v1',
|
||||
model: 'cline-pass/deepseek-v4-flash',
|
||||
apiKey: 'cline-test-key',
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
describe('applyProviderProfileToProcessEnv', () => {
|
||||
test('openai profile clears competing gemini/github flags', async () => {
|
||||
const { applyProviderProfileToProcessEnv } =
|
||||
@@ -828,6 +840,64 @@ describe('applyProviderProfileToProcessEnv', () => {
|
||||
expect(getFreshAPIProvider()).toBe('openai')
|
||||
})
|
||||
|
||||
test('ClinePass preset profile applies OpenAI-compatible env with CLINE_API_KEY mirror', async () => {
|
||||
const { applyProviderProfileToProcessEnv } =
|
||||
await importFreshProviderProfileModules()
|
||||
process.env.CLAUDE_CODE_USE_GEMINI = '1'
|
||||
|
||||
applyProviderProfileToProcessEnv(buildClinePassProfile())
|
||||
const { getAPIProvider: getFreshAPIProvider } =
|
||||
await importFreshProvidersModule()
|
||||
|
||||
expect(process.env.CLAUDE_CODE_USE_GEMINI).toBeUndefined()
|
||||
expect(String(process.env.CLAUDE_CODE_USE_OPENAI)).toBe('1')
|
||||
expect(process.env.OPENAI_BASE_URL).toBe('https://api.cline.bot/api/v1')
|
||||
expect(process.env.OPENAI_MODEL).toBe('cline-pass/deepseek-v4-flash')
|
||||
expect(process.env.OPENAI_API_KEY).toBe('cline-test-key')
|
||||
expect(process.env.CLINE_API_KEY).toBe('cline-test-key')
|
||||
expect(getFreshAPIProvider()).toBe('openai')
|
||||
})
|
||||
|
||||
test('custom openai profile targeting ClinePass base URL applies CLINE_API_KEY mirror', async () => {
|
||||
const { applyProviderProfileToProcessEnv } =
|
||||
await importFreshProviderProfileModules()
|
||||
|
||||
applyProviderProfileToProcessEnv(
|
||||
buildProfile({
|
||||
provider: 'openai',
|
||||
name: 'Custom ClinePass',
|
||||
baseUrl: 'https://api.cline.bot/api/v1',
|
||||
model: 'cline-pass/qwen3.7-max',
|
||||
apiKey: 'custom-cline-key',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(process.env.OPENAI_BASE_URL).toBe('https://api.cline.bot/api/v1')
|
||||
expect(process.env.OPENAI_MODEL).toBe('cline-pass/qwen3.7-max')
|
||||
expect(process.env.OPENAI_API_KEY).toBe('custom-cline-key')
|
||||
expect(process.env.CLINE_API_KEY).toBe('custom-cline-key')
|
||||
})
|
||||
|
||||
test('ClinePass provider with custom base URL applies CLINE_API_KEY mirror', async () => {
|
||||
const { applyProviderProfileToProcessEnv } =
|
||||
await importFreshProviderProfileModules()
|
||||
|
||||
applyProviderProfileToProcessEnv(
|
||||
buildProfile({
|
||||
provider: 'clinepass',
|
||||
name: 'ClinePass Custom',
|
||||
baseUrl: 'https://custom.cline.bot/v1',
|
||||
model: 'cline-pass/qwen3.7-max',
|
||||
apiKey: 'custom-cline-key',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(process.env.OPENAI_BASE_URL).toBe('https://custom.cline.bot/v1')
|
||||
expect(process.env.OPENAI_MODEL).toBe('cline-pass/qwen3.7-max')
|
||||
expect(process.env.OPENAI_API_KEY).toBe('custom-cline-key')
|
||||
expect(process.env.CLINE_API_KEY).toBe('custom-cline-key')
|
||||
})
|
||||
|
||||
test('legacy OpenAI profile on restricted route ignores advanced settings', async () => {
|
||||
const { applyProviderProfileToProcessEnv } =
|
||||
await importFreshProviderProfileModules()
|
||||
@@ -2203,6 +2273,94 @@ describe('setActiveProviderProfile', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('persists ClinePass profiles using a legacy-compatible openai startup profile', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-'))
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-'))
|
||||
process.chdir(tempDir)
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
|
||||
try {
|
||||
const { setActiveProviderProfile } =
|
||||
await importFreshProviderProfileModules()
|
||||
const clinePassProfile = buildClinePassProfile({
|
||||
id: 'clinepass_prof',
|
||||
model: 'cline-pass/deepseek-v4-flash, cline-pass/qwen3.7-max',
|
||||
})
|
||||
|
||||
saveMockGlobalConfig(current => ({
|
||||
...current,
|
||||
providerProfiles: [clinePassProfile],
|
||||
}))
|
||||
|
||||
const result = setActiveProviderProfile('clinepass_prof', {
|
||||
configDir,
|
||||
})
|
||||
const persisted = JSON.parse(
|
||||
readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'),
|
||||
)
|
||||
|
||||
expect(result?.id).toBe('clinepass_prof')
|
||||
expect(existsSync(join(tempDir, '.openclaude-profile.json'))).toBe(false)
|
||||
expect(persisted.profile).toBe('openai')
|
||||
expect(persisted.env).toEqual({
|
||||
OPENAI_BASE_URL: 'https://api.cline.bot/api/v1',
|
||||
OPENAI_MODEL: 'cline-pass/deepseek-v4-flash',
|
||||
OPENAI_API_KEY: 'cline-test-key',
|
||||
CLINE_API_KEY: 'cline-test-key',
|
||||
})
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
rmSync(configDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('persists ClinePass profiles with custom base URL using the same dedicated credential', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-'))
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-'))
|
||||
process.chdir(tempDir)
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
|
||||
try {
|
||||
const { setActiveProviderProfile } =
|
||||
await importFreshProviderProfileModules()
|
||||
const clinePassProfile = buildProfile({
|
||||
provider: 'clinepass',
|
||||
name: 'ClinePass Custom',
|
||||
baseUrl: 'https://custom.cline.bot/v1',
|
||||
model: 'cline-pass/qwen3.7-max',
|
||||
apiKey: 'custom-cline-key',
|
||||
id: 'clinepass_custom',
|
||||
})
|
||||
|
||||
saveMockGlobalConfig(current => ({
|
||||
...current,
|
||||
providerProfiles: [clinePassProfile],
|
||||
}))
|
||||
|
||||
const result = setActiveProviderProfile('clinepass_custom', {
|
||||
configDir,
|
||||
})
|
||||
const persisted = JSON.parse(
|
||||
readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'),
|
||||
)
|
||||
|
||||
expect(result?.id).toBe('clinepass_custom')
|
||||
expect(existsSync(join(tempDir, '.openclaude-profile.json'))).toBe(false)
|
||||
expect(persisted.profile).toBe('openai')
|
||||
expect(persisted.env).toEqual({
|
||||
OPENAI_BASE_URL: 'https://custom.cline.bot/v1',
|
||||
OPENAI_MODEL: 'cline-pass/qwen3.7-max',
|
||||
OPENAI_API_KEY: 'custom-cline-key',
|
||||
CLINE_API_KEY: 'custom-cline-key',
|
||||
})
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
rmSync(configDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('persists Xiaomi MiMo profiles using a legacy-compatible openai startup profile', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-'))
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-'))
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
type ProviderPreset,
|
||||
} from '../integrations/index.js'
|
||||
import {
|
||||
isClinePassBaseUrl,
|
||||
isFireworksBaseUrl,
|
||||
isNearaiBaseUrl,
|
||||
isXaiBaseUrl,
|
||||
@@ -136,6 +137,11 @@ function resolveProfileCompatibility(provider: string): {
|
||||
return { route, compatibilityMode: 'openai' }
|
||||
}
|
||||
|
||||
function isClinePassProfile(profile: ProviderProfile): boolean {
|
||||
const { route } = resolveProfileCompatibility(profile.provider)
|
||||
return route.routeId === 'clinepass' || isClinePassBaseUrl(profile.baseUrl)
|
||||
}
|
||||
|
||||
function deriveGithubEnterpriseUrl(baseUrl: string | undefined): string | undefined {
|
||||
if (!baseUrl?.trim()) return undefined
|
||||
try {
|
||||
@@ -696,6 +702,10 @@ function isProcessEnvAlignedWithProfile(
|
||||
? !includeApiKey ||
|
||||
sameOptionalEnvValue(processEnv.ATLAS_CLOUD_API_KEY, profile.apiKey)
|
||||
: true) &&
|
||||
(isClinePassProfile(profile)
|
||||
? !includeApiKey ||
|
||||
sameOptionalEnvValue(processEnv.CLINE_API_KEY, profile.apiKey)
|
||||
: true) &&
|
||||
(isNearaiBaseUrl(profile.baseUrl)
|
||||
? !includeApiKey ||
|
||||
sameOptionalEnvValue(processEnv.NEARAI_API_KEY, profile.apiKey)
|
||||
@@ -853,6 +863,9 @@ export function applyProviderProfileToProcessEnv(
|
||||
if (route.routeId === 'atlas-cloud' || profile.baseUrl.toLowerCase().includes('atlascloud')) {
|
||||
openAIProfileEnv.ATLAS_CLOUD_API_KEY = profile.apiKey
|
||||
}
|
||||
if (isClinePassProfile(profile)) {
|
||||
openAIProfileEnv.CLINE_API_KEY = profile.apiKey
|
||||
}
|
||||
if (route.routeId === 'nearai' || isNearaiBaseUrl(profile.baseUrl)) {
|
||||
openAIProfileEnv.NEARAI_API_KEY = profile.apiKey
|
||||
}
|
||||
@@ -1118,6 +1131,9 @@ function buildOpenAICompatibleStartupEnv(
|
||||
if (activeProfile.baseUrl?.toLowerCase().includes('atlascloud')) {
|
||||
strictEnv.ATLAS_CLOUD_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
if (isClinePassProfile(activeProfile)) {
|
||||
strictEnv.CLINE_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
if (isNearaiBaseUrl(activeProfile.baseUrl)) {
|
||||
strictEnv.NEARAI_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
@@ -1144,35 +1160,38 @@ function buildOpenAICompatibleStartupEnv(
|
||||
: {}),
|
||||
}
|
||||
|
||||
if (activeProfile.apiKey) {
|
||||
env.OPENAI_API_KEY = activeProfile.apiKey
|
||||
if (activeProfile.baseUrl?.toLowerCase().includes('bankr')) {
|
||||
env.BNKR_API_KEY = activeProfile.apiKey
|
||||
if (activeProfile.apiKey) {
|
||||
env.OPENAI_API_KEY = activeProfile.apiKey
|
||||
if (activeProfile.baseUrl?.toLowerCase().includes('bankr')) {
|
||||
env.BNKR_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
if (isXaiBaseUrl(activeProfile.baseUrl)) {
|
||||
env.XAI_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
if (activeProfile.baseUrl?.toLowerCase().includes('api.venice.ai')) {
|
||||
env.VENICE_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
if (
|
||||
activeProfile.baseUrl?.toLowerCase().includes('api.xiaomimimo.com') ||
|
||||
activeProfile.baseUrl?.toLowerCase().includes('api.mimo-v2.com')
|
||||
) {
|
||||
env.MIMO_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
if (activeProfile.baseUrl?.toLowerCase().includes('atlascloud')) {
|
||||
env.ATLAS_CLOUD_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
if (isClinePassProfile(activeProfile)) {
|
||||
env.CLINE_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
if (isNearaiBaseUrl(activeProfile.baseUrl)) {
|
||||
env.NEARAI_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
if (isFireworksBaseUrl(activeProfile.baseUrl)) {
|
||||
env.FIREWORKS_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
} else {
|
||||
delete env.OPENAI_API_KEY
|
||||
}
|
||||
if (isXaiBaseUrl(activeProfile.baseUrl)) {
|
||||
env.XAI_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
if (activeProfile.baseUrl?.toLowerCase().includes('api.venice.ai')) {
|
||||
env.VENICE_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
if (
|
||||
activeProfile.baseUrl?.toLowerCase().includes('api.xiaomimimo.com') ||
|
||||
activeProfile.baseUrl?.toLowerCase().includes('api.mimo-v2.com')
|
||||
) {
|
||||
env.MIMO_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
if (activeProfile.baseUrl?.toLowerCase().includes('atlascloud')) {
|
||||
env.ATLAS_CLOUD_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
if (isNearaiBaseUrl(activeProfile.baseUrl)) {
|
||||
env.NEARAI_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
if (isFireworksBaseUrl(activeProfile.baseUrl)) {
|
||||
env.FIREWORKS_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
} else {
|
||||
delete env.OPENAI_API_KEY
|
||||
}
|
||||
return applySupportedProfileCustomHeaders(activeProfile, env)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user