From 985984b9ffdf32bdd62fa01f0acbf69b135a2a53 Mon Sep 17 00:00:00 2001 From: JATMN Date: Mon, 29 Jun 2026 17:29:02 -0700 Subject: [PATCH] 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 --- .env.example | 12 + README.md | 1 + src/commands/usage/index.ts | 2 + src/components/ProviderManager.test.tsx | 1 + src/components/Settings/ClinePassUsage.tsx | 219 +++++++++++++++++ src/components/Settings/Usage.tsx | 5 + src/integrations/compatibility.test.ts | 1 + src/integrations/gateways/clinepass.ts | 230 ++++++++++++++++++ .../integrationArtifacts.generated.ts | 3 +- .../integrationManifest.generated.ts | 16 ++ src/integrations/routeMetadata.test.ts | 113 +++++++++ src/integrations/routeMetadata.ts | 97 +++++++- src/integrations/runtimeMetadata.ts | 4 +- src/services/api/clinepassUsage.test.ts | 190 +++++++++++++++ src/services/api/clinepassUsage.ts | 16 ++ src/services/api/clinepassUsage/fetch.ts | 80 ++++++ src/services/api/clinepassUsage/parse.ts | 130 ++++++++++ src/services/api/clinepassUsage/types.ts | 30 +++ src/services/api/providerConfig.test.ts | 113 +++++++++ src/services/api/providerConfig.ts | 54 +++- src/utils/providerProfile.ts | 3 + src/utils/providerProfiles.test.ts | 158 ++++++++++++ src/utils/providerProfiles.ts | 75 +++--- 23 files changed, 1501 insertions(+), 52 deletions(-) create mode 100644 src/components/Settings/ClinePassUsage.tsx create mode 100644 src/integrations/gateways/clinepass.ts create mode 100644 src/services/api/clinepassUsage.test.ts create mode 100644 src/services/api/clinepassUsage.ts create mode 100644 src/services/api/clinepassUsage/fetch.ts create mode 100644 src/services/api/clinepassUsage/parse.ts create mode 100644 src/services/api/clinepassUsage/types.ts diff --git a/.env.example b/.env.example index 14275d7b0..bfbe8db2e 100644 --- a/.env.example +++ b/.env.example @@ -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 # ============================================================================= diff --git a/README.md b/README.md index 5e9f98251..3bc01fee7 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/src/commands/usage/index.ts b/src/commands/usage/index.ts index ba4858808..836ae83f9 100644 --- a/src/commands/usage/index.ts +++ b/src/commands/usage/index.ts @@ -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' diff --git a/src/components/ProviderManager.test.tsx b/src/components/ProviderManager.test.tsx index c182abf3f..72bc96a46 100644 --- a/src/components/ProviderManager.test.tsx +++ b/src/components/ProviderManager.test.tsx @@ -116,6 +116,7 @@ const PRESET_ORDER = [ 'Atlas Cloud', 'Azure OpenAI', 'Bankr', + 'ClinePass', 'DeepSeek', 'Codex OAuth', 'xAI OAuth (Grok)', diff --git a/src/components/Settings/ClinePassUsage.tsx b/src/components/Settings/ClinePassUsage.tsx new file mode 100644 index 000000000..82e2846f9 --- /dev/null +++ b/src/components/Settings/ClinePassUsage.tsx @@ -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 ( + + + {label} + {resetText ? · {resetText} : null} + + + + {usedText} + + + ) +} + +export function ClinePassUsage(): React.ReactNode { + const [usage, setUsage] = useState(null) + const [error, setError] = useState(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 ( + + Error: {error} + + + + + + + + ) + } + + if (!usage) { + return ( + + Loading ClinePass usage data… + + + + + ) + } + + const rows = + usage.availability === 'available' + ? buildClinePassUsageRows(usage.windows) + : [] + + return ( + + {usage.planName ? Plan: {usage.planName} : null} + + {usage.availability === 'unknown' ? ( + {usage.message} + ) : rows.length === 0 ? ( + + No ClinePass usage windows were returned for this account. + + ) : null} + + {rows.map((row, index) => ( + + ))} + + + + + + ) +} diff --git a/src/components/Settings/Usage.tsx b/src/components/Settings/Usage.tsx index c47d73ecb..09210f8af 100644 --- a/src/components/Settings/Usage.tsx +++ b/src/components/Settings/Usage.tsx @@ -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 ; } + if (usageDescriptor.resolvedId === 'clinepass' && usageDescriptor.supported) { + return ; + } if (usageDescriptor.resolvedId === 'anthropic' && usageDescriptor.supported) { return ; } diff --git a/src/integrations/compatibility.test.ts b/src/integrations/compatibility.test.ts index ee18ae711..3a73d2d7e 100644 --- a/src/integrations/compatibility.test.ts +++ b/src/integrations/compatibility.test.ts @@ -48,6 +48,7 @@ const EXPECTED_PRESETS = [ 'fireworks', 'opencode', 'opencode-go', + 'clinepass', ] as const satisfies readonly ProviderPreset[] describe('compatibility mappings', () => { diff --git a/src/integrations/gateways/clinepass.ts b/src/integrations/gateways/clinepass.ts new file mode 100644 index 000000000..72645c057 --- /dev/null +++ b/src/integrations/gateways/clinepass.ts @@ -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, + }, + }, +}) diff --git a/src/integrations/generated/integrationArtifacts.generated.ts b/src/integrations/generated/integrationArtifacts.generated.ts index 41052e79a..6f795fbcd 100644 --- a/src/integrations/generated/integrationArtifacts.generated.ts +++ b/src/integrations/generated/integrationArtifacts.generated.ts @@ -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[])[] diff --git a/src/integrations/generated/integrationManifest.generated.ts b/src/integrations/generated/integrationManifest.generated.ts index 9544977ec..9157b0299 100644 --- a/src/integrations/generated/integrationManifest.generated.ts +++ b/src/integrations/generated/integrationManifest.generated.ts @@ -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", diff --git a/src/integrations/routeMetadata.test.ts b/src/integrations/routeMetadata.test.ts index 5ca227fe1..38f9db0d3 100644 --- a/src/integrations/routeMetadata.test.ts +++ b/src/integrations/routeMetadata.test.ts @@ -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({ diff --git a/src/integrations/routeMetadata.ts b/src/integrations/routeMetadata.ts index f23de30be..4ec1a0286 100644 --- a/src/integrations/routeMetadata.ts +++ b/src/integrations/routeMetadata.ts @@ -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' } diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index 5dbdea96c..05446e16b 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -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) diff --git a/src/services/api/clinepassUsage.test.ts b/src/services/api/clinepassUsage.test.ts new file mode 100644 index 000000000..8f8f07f29 --- /dev/null +++ b/src/services/api/clinepassUsage.test.ts @@ -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).MACRO + + beforeEach(() => { + process.env.CLINE_API_KEY = 'cp-test-key' + originalFetch = globalThis.fetch + ;(globalThis as unknown as Record).MACRO = { VERSION: 'test' } + }) + + afterEach(() => { + mock.restore() + globalThis.fetch = originalFetch + ;(globalThis as unknown as Record).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)?.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') + }) +}) diff --git a/src/services/api/clinepassUsage.ts b/src/services/api/clinepassUsage.ts new file mode 100644 index 000000000..44b576dd4 --- /dev/null +++ b/src/services/api/clinepassUsage.ts @@ -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' diff --git a/src/services/api/clinepassUsage/fetch.ts b/src/services/api/clinepassUsage/fetch.ts new file mode 100644 index 000000000..010fc2d5f --- /dev/null +++ b/src/services/api/clinepassUsage/fetch.ts @@ -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 { + 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() + } +} diff --git a/src/services/api/clinepassUsage/parse.ts b/src/services/api/clinepassUsage/parse.ts new file mode 100644 index 000000000..37f35b7a3 --- /dev/null +++ b/src/services/api/clinepassUsage/parse.ts @@ -0,0 +1,130 @@ +import { + DEFAULT_CLINEPASS_UNAVAILABLE_MESSAGE, + type ClinePassUsageData, + type ClinePassUsageRow, + type ClinePassUsageWindow, +} from './types.js' + +type RecordLike = Record + +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 = { + 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 +} diff --git a/src/services/api/clinepassUsage/types.ts b/src/services/api/clinepassUsage/types.ts new file mode 100644 index 000000000..a90605678 --- /dev/null +++ b/src/services/api/clinepassUsage/types.ts @@ -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.' diff --git a/src/services/api/providerConfig.test.ts b/src/services/api/providerConfig.test.ts index 365282f3f..540924717 100644 --- a/src/services/api/providerConfig.test.ts +++ b/src/services/api/providerConfig.test.ts @@ -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') +}) diff --git a/src/services/api/providerConfig.ts b/src/services/api/providerConfig.ts index 93c8acd89..b80b3cbc7 100644 --- a/src/services/api/providerConfig.ts +++ b/src/services/api/providerConfig.ts @@ -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() @@ -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, diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index af73d25a6..43fc8e9ea 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -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 diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index 690451727..312f5e5d2 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -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 = {}): Provi }) } +function buildClinePassProfile(overrides: Partial = {}): 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-')) diff --git a/src/utils/providerProfiles.ts b/src/utils/providerProfiles.ts index 7e19cfa56..cc4287282 100644 --- a/src/utils/providerProfiles.ts +++ b/src/utils/providerProfiles.ts @@ -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) }