diff --git a/CHANGELOG.md b/CHANGELOG.md index b7c76f4c65..6001d8cb1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **Per-combo reasoning token buffer toggle**: the combo builder now exposes an explicit checkbox for the `#3587` reasoning-model `max_tokens` buffer, defaulting to the existing enabled behavior, so a combo can opt out without hand-editing raw JSON config (#6702 — thanks @xz-dev) - **feat(dashboard):** 9router-parity **Routing Strategy** settings card on Settings → Routing, plus a per-provider account-routing override on the provider detail page ([#6678](https://github.com/diegosouzapw/OmniRoute/pull/6678)) — surfaces the existing account round-robin / sticky-limit knobs and adds a new combo-level sticky round-robin (`comboStickyRoundRobinLimit`, resolved via `resolveComboStickyRoundRobinLimit()` — per-combo → global combo sticky → account sticky cascade) so combo targets can batch calls per target the same way account fallback already does. A new `providerStrategies` setting (Zod-validated map, `src/shared/validation/settingsSchemas.ts`) lets a specific provider override the global `fallbackStrategy`/`stickyRoundRobinLimit` without touching the account-wide default, wired into `getProviderCredentials()` (`src/sse/services/auth.ts`) ahead of the global fallback. Regression guard: `tests/unit/combo-rr-sticky-9router.test.ts`, `tests/unit/settings-ui-layout-static.test.ts`. (thanks @SeaXen) - **Skill Collector CLI detection**: new `GET /api/skills/collect/detect` + `POST /api/skills/collect/install` (and the `cli-skill-collector` agent skill) detect which coding CLIs (Claude Code, Codex, Cursor, Copilot, Cline, Hermes, OpenCode, etc.) are installed locally via `getCliRuntimeStatus()`, match them against GitHub agent-skill repos, and plan an install path per tool — replacing the standalone Skill Collector Python app. Both new routes and `GET/POST /api/github-skills` now require management auth (`requireManagementAuth()`) and are loopback-gated (`LOCAL_ONLY_API_PREFIXES` + `SPAWN_CAPABLE_PREFIXES`) since the detect route spawns a child process per candidate CLI tool (Hard Rules #15 + #17). The `omniroute_github_skills_install` MCP tool now reports the honest `action: "planned"` instead of `"installed"`, matching the REST route (#6294 — thanks @Moseyuh333) +- **ClinePass dual-auth**: ClinePass now offers both sign-in methods on its dashboard page — OAuth (reusing the Cline WorkOS flow) as the primary "Connect" path, or a pasted BYOK API key via "Manual API key", instead of only the API-key-only provider shipped in #5942. The registry alias was aligned to `cp` (matching the `OAUTH_PROVIDERS` catalog alias) so `/` routing resolves correctly, the OAuth refresh dispatch now routes `clinepass` to the shared Cline refresh flow, and the duplicate API-key-only catalog entry was removed to keep ClinePass listed once. Regression guard: `tests/unit/clinepass-provider.test.ts`. (#6126 — thanks @hajilok) ### 🐛 Bug Fixes diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index cebc56620a..9653ffa19a 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -182,7 +182,8 @@ "open-sse/services/compression/strategySelector.ts": 1043, "open-sse/services/rateLimitManager.ts": 1035, "_rebaseline_2026_06_29_4038_cas_guard": "PR (#4038) own growth: tokenRefresh.ts 2103->2181 (+78 = the compare-and-swap guard on the refresh persist — runWithCasGuard/getActiveCasGuard AsyncLocalStorage pair mirroring runWithOnPersist, casGuardShouldSkipPersist that rereads the row right before persisting and skips the write when a concurrent writer already rotated the refresh_token past the one presented, plus getCasGuardStats counters). Fixes the sibling-rotation-revert → token-family-revocation storm. Gated behind an active guard (opt-in; no guard => byte-identical). Wiring lives at the two persist chokepoints inside getAccessToken; the comparison reuses wasRefreshTokenRotated from refreshSerializer. Not extractable without splitting the refresh hot path.", - "open-sse/services/tokenRefresh.ts": 2181, + "_rebaseline_2026_07_09_6126_clinepass_dual_auth": "PR #6126 (@hajilok, dual-auth ClinePass) own growth: tokenRefresh.ts 2181->2182 (+1 = a single `case \"clinepass\":` fallthrough label added to the existing `case \"cline\":` in _getAccessTokenInternal's provider switch, so clinepass token refresh dispatches to the already-shared refreshClineToken() instead of silently falling through to the generic OAuth refresh). Irreducible 1-line switch-case wiring at the existing chokepoint; the header-building logic for the same feature was extracted to a new leaf src/shared/utils/clineAuth.ts::buildClinepassHeaders() (well under cap) to avoid growing open-sse/executors/default.ts. Covered by tests/unit/clinepass-provider.test.ts.", + "open-sse/services/tokenRefresh.ts": 2182, "open-sse/services/usage.ts": 3454, "open-sse/translator/request/openai-to-gemini.ts": 906, "open-sse/translator/request/openai-to-kiro.ts": 890, @@ -276,6 +277,7 @@ }, "testCap": 800, "testFrozen": { + "_rebaseline_2026_07_09_6126_clinepass_dualauth": "#6126 (ClinePass dual-auth) own test growth: oauth-providers-config.test.ts 842->845 (+3: clinepass key/config/required-fields entries reusing the Cline WorkOS flow config, needed after registering clinepass in the oauth.ts PROVIDERS enum).", "_rebaseline_2026_06_27_5193_antigravity_test": "#5193 own test growth: oauth-providers-config.test.ts 870->873 (+3: antigravity projectId assertion + 50ms tick for the now fire-and-forget onboarding, matching the no-PKCE/no-openid flow).", "_rebaseline_2026_07_02_5928_base_red": "web-cookie-providers-new.test.ts 845->850: #5928 (test(security) Kimi Web URL host parse, CodeQL #689) grew the file +5 lines and merged into release/v3.8.44 WITHOUT rebaselining, leaving a fast-gates base-red that blocked every subsequent PR->release. Test growth is legitimate (a security regression test); maintainer absorbs the drift here. Frozen at 850.", "_rebaseline_basered_codebuddy_cn": "Base-red fix (#4664 CodeBuddy CN): oauth-providers-config.test.ts 867->870 (+3) to align the EXPECTED provider list/config with the codebuddy-cn provider that #4664 added to the registry without updating this test (it asserts 'exactly once').", @@ -305,7 +307,7 @@ "tests/unit/image-generation-handler.test.ts": 2019, "tests/unit/model-sync-route.test.ts": 1016, "tests/unit/models-catalog-route.test.ts": 1605, - "tests/unit/oauth-providers-config.test.ts": 842, + "tests/unit/oauth-providers-config.test.ts": 845, "tests/unit/perplexity-web.test.ts": 999, "tests/unit/provider-models-route.test.ts": 1752, "tests/unit/provider-validation-specialty.test.ts": 2856, diff --git a/open-sse/config/providers/registry/clinepass/index.ts b/open-sse/config/providers/registry/clinepass/index.ts index e6d3697a03..12a57c76ff 100644 --- a/open-sse/config/providers/registry/clinepass/index.ts +++ b/open-sse/config/providers/registry/clinepass/index.ts @@ -1,22 +1,33 @@ import type { RegistryEntry } from "../../shared.ts"; -// ClinePass — Cline's $9.99/mo BYOK API-key gateway (https://cline.bot). Distinct -// from the OAuth `cline` provider: same host (api.cline.bot) but a plain Bearer -// API key and the `cline-pass/*` model namespace. Responses are wrapped in a -// {success, data} envelope — unwrapped by open-sse/utils/clinepassEnvelope.ts. +// ClinePass — Cline's $9.99/mo gateway (https://cline.bot). Dual-auth: sign in +// with a Cline account (OAuth, reusing the `cline` WorkOS flow) OR paste a direct +// BYOK API key. Same host (api.cline.bot) as the OAuth `cline` provider; the +// `cline-pass/*` model namespace. Responses are wrapped in a {success, data} +// envelope — unwrapped by open-sse/utils/clinepassEnvelope.ts. export const clinepassProvider: RegistryEntry = { id: "clinepass", - alias: "clinepass", + // MUST match the OAUTH_PROVIDERS catalog alias (src/shared/constants/providers/oauth.ts). + // The dashboard sends models as `/` (e.g. "cp/cline-pass/glm-5.2"), + // and routing resolves the prefix via ALIAS_TO_PROVIDER_ID (built from this field). If the + // registry alias drifts from the catalog alias, the prefix won't resolve, the executor falls + // back to PROVIDERS.openai, and requests hit api.openai.com with the ClinePass key → 401. + alias: "cp", format: "openai", executor: "default", // ClinePass shares Cline's streaming-only API — a non-streaming request returns // "generateText is not implemented" / an empty body. Force upstream streaming; // chatCore accumulates the SSE and converts it back to JSON for stream:false - // clients. (Same as the sibling `cline` provider.) + // clients. (Same as the sibling `cline` provider. #6165.) forceStream: true, baseUrl: "https://api.cline.bot/api/v1/chat/completions", - authType: "apikey", + authType: "oauth", authHeader: "bearer", + oauth: { + tokenUrl: "https://api.cline.bot/api/v1/auth/token", + refreshUrl: "https://api.cline.bot/api/v1/auth/refresh", + authUrl: "https://api.cline.bot/api/v1/auth/authorize", + }, extraHeaders: { "HTTP-Referer": "https://cline.bot", "X-Title": "Cline", diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index b2697a349e..f3bd3930c8 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -44,7 +44,7 @@ import { buildMaritalkChatUrl } from "../config/maritalk.ts"; import { LOCAL_PROVIDERS } from "@/shared/constants/providers"; import { isForbiddenCustomHeaderName } from "@/shared/constants/upstreamHeaders"; import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults"; -import { buildClineHeaders } from "@/shared/utils/clineAuth"; +import { buildClineHeaders, buildClinepassHeaders } from "@/shared/utils/clineAuth"; import { normalizeHerokuChatUrl, normalizeDatabricksChatUrl, @@ -369,6 +369,9 @@ export class DefaultExecutor extends BaseExecutor { case "glm-coding-apikey": headers["x-api-key"] = effectiveKey || credentials.accessToken; break; + case "clinepass": // dual-auth (OAuth or BYOK) — see buildClinepassHeaders() + Object.assign(headers, buildClinepassHeaders(credentials, effectiveKey)); + break; case "cline": // Cline's API requires the bearer token prefixed with `workos:` plus a // set of Cline client-identification headers; plain `Bearer ` diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 338ddca604..4d9841342d 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -1602,6 +1602,7 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: ); case "cline": + case "clinepass": // reuses the Cline WorkOS refresh flow (clinepass: cline) return await refreshClineToken(credentials.refreshToken, log, proxyConfig); case "kimi-coding": diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx index 7a336bb49a..2fe7c8d0e3 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx @@ -247,17 +247,22 @@ export default function ConnectionsHeaderToolbar({ )} {!isCompatible ? ( <> - {isCommandCode ? ( + {isCommandCode || providerId === "clinepass" ? ( <> diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx index 24478d2f7d..af06a7a2cd 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx @@ -55,16 +55,21 @@ export default function EmptyConnectionsPlaceholder({

{t("addFirstConnectionHint")}

{!isCompatible && (
- {isCommandCode ? ( + {isCommandCode || providerId === "clinepass" ? ( <> diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 4ca6d3a70a..b9c36adc26 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -484,6 +484,7 @@ export const PROVIDERS = { CURSOR: "cursor", KILOCODE: "kilocode", CLINE: "cline", + CLINEPASS: "clinepass", WINDSURF: "windsurf", DEVIN_CLI: "devin-cli", TRAE: "trae", diff --git a/src/lib/oauth/providers/index.ts b/src/lib/oauth/providers/index.ts index 1be452e2c5..e2f9e10847 100644 --- a/src/lib/oauth/providers/index.ts +++ b/src/lib/oauth/providers/index.ts @@ -46,6 +46,10 @@ export const PROVIDERS = { trae, kilocode, cline, + // clinepass reuses the Cline WorkOS OAuth flow 1:1 (same api.cline.bot host, same token + // type) — it is a separate catalog entry advertising the cline-pass/* (ClinePass + // subscription) models. See registry/clinepass/index.ts. + clinepass: cline, windsurf, // devin-cli shares the same token format as windsurf (WINDSURF_API_KEY / devin auth login) "devin-cli": windsurf, diff --git a/src/lib/providers/catalog.ts b/src/lib/providers/catalog.ts index 1c9d5e4621..ec4da40838 100644 --- a/src/lib/providers/catalog.ts +++ b/src/lib/providers/catalog.ts @@ -195,8 +195,22 @@ export function resolveStaticProviderCatalogEntry( return null; } +/** + * OAuth-primary providers that ALSO accept a direct BYOK API key (dual-auth), + * admitted through the managed-connection API-key gate independent of the OAuth + * catalog. These are deliberately kept OUT of `FREE_APIKEY_PROVIDER_IDS`: that + * set flips `providerSupportsPat` true, which turns `isOAuth` false and would + * make the dashboard's primary "Connect" button route to the API-key modal + * instead of the OAuth flow. Admitting them here lets POST /api/providers + * persist an `apikey` connection (the reliable BYOK path) while the provider + * stays OAuth-primary (isOAuth=true). clinepass is the dual-auth case: sign in + * with a Cline account OR paste a ClinePass API key. + */ +const DUAL_AUTH_APIKEY_PROVIDER_IDS = new Set(["clinepass"]); + export function isManagedProviderConnectionId(providerId: string): boolean { if (supportsApiKeyOnFreeProvider(providerId)) return true; + if (DUAL_AUTH_APIKEY_PROVIDER_IDS.has(providerId)) return true; const entry = resolveStaticProviderCatalogEntry(providerId); return !!(entry && MANAGED_PROVIDER_CONNECTION_CATEGORIES.has(entry.category)); diff --git a/src/shared/constants/providers/apikey/gateways.ts b/src/shared/constants/providers/apikey/gateways.ts index cbf08cc122..59d1b10fde 100644 --- a/src/shared/constants/providers/apikey/gateways.ts +++ b/src/shared/constants/providers/apikey/gateways.ts @@ -41,20 +41,6 @@ export const APIKEY_PROVIDERS_GATEWAYS = { "Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint.", apiHint: "Create or copy an API key from Command Code, then paste it here as a Bearer token.", }, - clinepass: { - id: "clinepass", - alias: "clinepass", - name: "ClinePass", - icon: "vpn_key", - color: "#5B9BD5", - textIcon: "CP", - passthroughModels: true, - website: "https://cline.bot", - notice: { - text: "ClinePass is Cline's paid BYOK gateway ($9.99/mo). Bring your own Cline API key; requests hit api.cline.bot with the cline-pass/* model namespace.", - apiKeyUrl: "https://app.cline.bot/settings/api-keys", - }, - }, openrouter: { id: "openrouter", alias: "openrouter", diff --git a/src/shared/constants/providers/oauth.ts b/src/shared/constants/providers/oauth.ts index 14bdac4366..7eda70e794 100644 --- a/src/shared/constants/providers/oauth.ts +++ b/src/shared/constants/providers/oauth.ts @@ -191,6 +191,19 @@ export const OAUTH_PROVIDERS = { subscriptionRisk: true, riskNoticeVariant: "oauth", }, + clinepass: { + id: "clinepass", + alias: "cp", + name: "ClinePass", + icon: "smart_toy", + color: "#9D4EDD", + textIcon: "CP", + website: "https://cline.bot/clinepass", + subscriptionRisk: true, + riskNoticeVariant: "oauth", + authHint: + "ClinePass is Cline's $9.99/mo subscription bundling 10 open coding models. Sign in with your Cline account (same login as the Cline CLI/IDE), or paste a direct ClinePass API key (app.cline.bot → Settings → API Keys). A ClinePass subscription unlocks the cline-pass/* models. Reuses the Cline WorkOS OAuth flow.", + }, windsurf: { id: "windsurf", alias: "ws", diff --git a/src/shared/utils/clineAuth.ts b/src/shared/utils/clineAuth.ts index 929e61cbae..13a52af98b 100644 --- a/src/shared/utils/clineAuth.ts +++ b/src/shared/utils/clineAuth.ts @@ -60,3 +60,26 @@ export function buildClineHeaders( return headers; } + +/** + * Build headers for a ClinePass request. ClinePass is dual-auth: an OAuth + * connection (workos:-prefixed token in `accessToken`) needs the full Cline + * client header set from `buildClineHeaders()`; a BYOK API-key connection + * (`sk_...` key, #5942) sends the key as a plain Bearer token — no `workos:` + * prefix — alongside the Cline identification headers. + */ +export function buildClinepassHeaders( + credentials: { accessToken?: unknown; apiKey?: unknown } | null | undefined, + effectiveKey?: string +): Record { + if (credentials?.accessToken) { + return buildClineHeaders(credentials.accessToken); + } + const headers: Record = { + "HTTP-Referer": "https://cline.bot", + "X-Title": "Cline", + }; + const byokKey = effectiveKey || (credentials?.apiKey as string | undefined); + if (byokKey) headers.Authorization = `Bearer ${byokKey}`; + return headers; +} diff --git a/tests/unit/clinepass-provider.test.ts b/tests/unit/clinepass-provider.test.ts index 1706e1cc56..6465752a93 100644 --- a/tests/unit/clinepass-provider.test.ts +++ b/tests/unit/clinepass-provider.test.ts @@ -1,34 +1,39 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { APIKEY_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); +const { APIKEY_PROVIDERS, OAUTH_PROVIDERS, supportsApiKeyOnFreeProvider } = + await import("../../src/shared/constants/providers.ts"); +const { isManagedProviderConnectionId } = await import("../../src/lib/providers/catalog.ts"); +const { PROVIDERS: oauthFlows } = await import("../../src/lib/oauth/providers/index.ts"); const { REGISTRY: providerRegistry } = await import("../../open-sse/config/providerRegistry.ts"); const { unwrapClinepassEnvelope } = await import("../../open-sse/utils/clinepassEnvelope.ts"); const { filterClinepassModels } = await import("../../open-sse/services/clinepassModels.ts"); const { parseUpstreamError, buildErrorBody } = await import("../../open-sse/utils/error.ts"); -// ── Provider metadata (Zod-validated APIKEY catalog) ───────────────────────── -test("ClinePass is registered as an API-key provider with the canonical identity", () => { - const cp = APIKEY_PROVIDERS.clinepass; - assert.ok(cp, "APIKEY_PROVIDERS.clinepass must be defined"); +// ── Provider metadata (oauth-primary catalog; single provider) ────────────── +test("ClinePass is registered as an OAuth-primary provider with the canonical identity", () => { + const cp = OAUTH_PROVIDERS.clinepass; + assert.ok(cp, "OAUTH_PROVIDERS.clinepass must be defined (oauth-primary catalog)"); assert.equal(cp.id, "clinepass"); - assert.equal(cp.alias, "clinepass"); assert.equal(cp.name, "ClinePass"); - assert.equal(cp.website, "https://cline.bot"); - assert.equal( - (cp as { notice?: { apiKeyUrl?: string } }).notice?.apiKeyUrl, - "https://app.cline.bot/settings/api-keys" + // Single provider — NO duplicate APIKEY_PROVIDERS entry. Dual-auth (OAuth sign-in + // + Manual API key) is rendered by the dashboard's isOAuth branch (same as + // cline/claude), not via FREE_APIKEY_PROVIDER_IDS (which would flip isOAuth off). + assert.ok( + !APIKEY_PROVIDERS.clinepass, + "clinepass must NOT be in APIKEY_PROVIDERS (single provider)" ); }); -test("ClinePass registry entry uses OpenAI format with bearer apikey auth + Cline headers", () => { +test("ClinePass registry entry is oauth-primary (dual-auth) with Cline headers", () => { const entry = providerRegistry.clinepass; assert.ok(entry, "providerRegistry.clinepass must be defined"); assert.equal(entry.id, "clinepass"); assert.equal(entry.format, "openai"); assert.equal(entry.executor, "default"); - assert.equal(entry.authType, "apikey"); + assert.equal(entry.authType, "oauth"); assert.equal(entry.authHeader, "bearer"); + assert.ok(entry.oauth, "must carry the Cline OAuth urls (sign-in path)"); assert.equal(entry.baseUrl, "https://api.cline.bot/api/v1/chat/completions"); assert.equal(entry.extraHeaders?.["HTTP-Referer"], "https://cline.bot"); assert.equal(entry.extraHeaders?.["X-Title"], "Cline"); @@ -115,3 +120,61 @@ test("parseUpstreamError unwraps clinepass envelope error without leaking a stac const body = buildErrorBody(502, parsed.message) as { error: { message: string } }; assert.ok(!body.error.message.includes("at /"), "sanitized error must not include a stack frame"); }); + +// ── Dual-auth: clinepass accepts BOTH an API key (#5942) AND OAuth login ───── +test("ClinePass is also in the OAuth catalog (dual-auth: API-key + OAuth login)", () => { + const cp = OAUTH_PROVIDERS.clinepass; + assert.ok(cp, "OAUTH_PROVIDERS.clinepass must be defined for the OAuth login path"); + assert.equal(cp.id, "clinepass"); + assert.equal(cp.name, "ClinePass"); +}); + +test("ClinePass reuses the Cline WorkOS OAuth flow (clinepass -> cline)", () => { + assert.ok(oauthFlows.clinepass, "clinepass must map to an OAuth flow"); + assert.equal( + oauthFlows.clinepass, + oauthFlows.cline, + "clinepass must reuse the cline OAuth flow 1:1 (same api.cline.bot host/token)" + ); +}); + +test("ClinePass is a single OAuth-primary provider (no duplicate catalog entry)", () => { + assert.ok(OAUTH_PROVIDERS.clinepass, "OAuth catalog entry"); + assert.ok(!APIKEY_PROVIDERS.clinepass, "no duplicate APIKEY_PROVIDERS entry"); +}); + +// ── Dual-auth API-key admission (POST /api/providers gate) ─────────────────── +// clinepass is OAuth-primary (isOAuth=true → "Connect" opens the OAuth flow) but +// ALSO accepts a pasted BYOK API key. The API-key path must pass the managed- +// connection gate (isManagedProviderConnectionId) WITHOUT flipping isOAuth off. +// That means admitting it through the dedicated DUAL_AUTH set, NOT through +// FREE_APIKEY_PROVIDER_IDS (which would set providerSupportsPat=true → isOAuth=false +// and break the primary Connect→OAuth routing). Regression guard for the layout. +test("ClinePass API-key connections pass the managed gate while staying OAuth-primary", () => { + assert.ok( + isManagedProviderConnectionId("clinepass"), + "POST /api/providers must accept a clinepass apikey connection (dual-auth BYOK path)" + ); + assert.ok( + !supportsApiKeyOnFreeProvider("clinepass"), + "clinepass must NOT be in FREE_APIKEY_PROVIDER_IDS — that would flip isOAuth false" + ); +}); + +// ── Catalog ↔ registry alias consistency (routing prefix) ─────────────────── +// The dashboard sends models as `/` (e.g. "cp/cline-pass/glm-5.2"). +// Routing resolves that prefix via ALIAS_TO_PROVIDER_ID, which is built from the REGISTRY +// alias (generateAliasMap). If the registry alias drifts from the catalog alias, the prefix +// won't resolve → executor falls back to PROVIDERS.openai → requests hit api.openai.com +// with the ClinePass key → a misleading OpenAI 401. cline keeps these in sync (both "cl"); +// clinepass must too. Regression guard for the cp/cline-pass/* OpenAI-401 incident. +test("ClinePass registry alias matches the OAUTH_PROVIDERS catalog alias (routing prefix)", () => { + const cp = OAUTH_PROVIDERS.clinepass; + assert.ok(cp?.alias, "catalog alias must be defined"); + assert.equal( + providerRegistry.clinepass.alias, + cp.alias, + "registry alias must equal catalog alias so / resolves to clinepass" + ); + assert.equal(providerRegistry.clinepass.alias, "cp"); +}); diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index 7637b3e051..1e4a814e34 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -64,6 +64,7 @@ const EXPECTED_PROVIDER_KEYS = [ "trae", "kilocode", "cline", + "clinepass", "windsurf", "devin-cli", "grok-cli", @@ -87,6 +88,7 @@ const EXPECTED_CONFIG_BY_PROVIDER = { cursor: CURSOR_CONFIG, kilocode: KILOCODE_CONFIG, cline: CLINE_CONFIG, + clinepass: CLINE_CONFIG, // reuses the Cline WorkOS flow (clinepass: cline in providers/index.ts) windsurf: WINDSURF_CONFIG, "devin-cli": WINDSURF_CONFIG, trae: TRAE_CONFIG, @@ -138,6 +140,7 @@ const REQUIRED_FIELDS_BY_PROVIDER = { cursor: ["apiEndpoint", "api3Endpoint", "agentEndpoint", "agentNonPrivacyEndpoint", "dbKeys"], kilocode: ["apiBaseUrl", "initiateUrl", "pollUrlBase"], cline: ["appBaseUrl", "apiBaseUrl", "authorizeUrl", "tokenExchangeUrl", "refreshUrl"], + clinepass: ["appBaseUrl", "apiBaseUrl", "authorizeUrl", "tokenExchangeUrl", "refreshUrl"], windsurf: ["authorizeUrl", "apiServerUrl", "exchangePath", "inferenceUrl"], "devin-cli": ["authorizeUrl", "apiServerUrl", "exchangePath", "inferenceUrl"], trae: ["apiEndpoint", "chatEndpoint", "webUrl"], diff --git a/tests/unit/providers-constants-split.test.ts b/tests/unit/providers-constants-split.test.ts index 419cddaa78..51cfd53754 100644 --- a/tests/unit/providers-constants-split.test.ts +++ b/tests/unit/providers-constants-split.test.ts @@ -1,9 +1,12 @@ // Characterization of the providers.ts catalog split (god-file decomposition): the host became a // barrel that re-exports 10 data catalogs now living under constants/providers/*, and APIKEY is // merged from 6 semantic family files (apikey/.ts). Locks: the public surface (every catalog -// + helpers still exported), the spread-merge integrity (167 APIKEY entries, no loss/dup), and that +// + helpers still exported), the spread-merge integrity (166 APIKEY entries, no loss/dup), and that // load-time Zod validation still runs. Pure-data move → behavior must be identical. -// Count was 171 before obsolete provider removals (PR #6675: glhf/kluster/cablyai/inclusionai etc.). +// Count was 171 before obsolete provider removals (PR #6675: glhf/kluster/cablyai/inclusionai etc., +// 171->167) plus #6126 (ClinePass dual-auth): the API-key-only APIKEY_PROVIDERS_GATEWAYS entry was +// removed as a duplicate now that clinepass is OAuth-primary (OAUTH_PROVIDERS.clinepass) with its +// BYOK path admitted through the DUAL_AUTH_APIKEY_PROVIDER_IDS gate instead (167->166). import { test } from "node:test"; import assert from "node:assert/strict"; @@ -32,12 +35,12 @@ test("barrel still exports every catalog + key helpers", () => { } }); -test("APIKEY_PROVIDERS merges the 6 family files into 167 entries (no loss / no dup)", async () => { +test("APIKEY_PROVIDERS merges the 6 family files into 166 entries (no loss / no dup)", async () => { const keys = Object.keys((P as Record).APIKEY_PROVIDERS); - assert.equal(keys.length, 167); - assert.equal(new Set(keys).size, 167, "duplicate keys after spread-merge"); + assert.equal(keys.length, 166); + assert.equal(new Set(keys).size, 166, "duplicate keys after spread-merge"); // the merged object's entry-count equals the sum of the 6 semantic family files; families are a - // strict partition (every provider in exactly one), so the sum must be exactly 167. + // strict partition (every provider in exactly one), so the sum must be exactly 166. const families: [string, string][] = [ ["gateways", "APIKEY_PROVIDERS_GATEWAYS"], ["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"], @@ -57,7 +60,7 @@ test("APIKEY_PROVIDERS merges the 6 family files into 167 entries (no loss / no seen.add(k); } } - assert.equal(famTotal, 167, "families must partition all 167 providers"); + assert.equal(famTotal, 166, "families must partition all 166 providers"); }); test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => {