feat(providers): ClinePass OAuth login — dual-auth on top of #5942 (reconciles #5924) (#6126)

* feat(providers): rebase ClinePass dual-auth (OAuth + BYOK) onto release/v3.8.47

ClinePass now offers both sign-in methods on its dashboard page: OAuth
(reusing the Cline WorkOS flow, primary "Connect" button) or a pasted
BYOK API key ("Manual API key"), instead of only the API-key-only
provider shipped in #5942.

- Registry: authType oauth + oauth urls, alias aligned to "cp" (matches
  the OAUTH_PROVIDERS catalog alias so <alias>/<modelId> routing
  resolves); keeps the #6165 forceStream:true fix (streaming-only API).
- Executor: new buildClinepassHeaders() (src/shared/utils/clineAuth.ts)
  picks buildClineHeaders() for an OAuth accessToken or a plain Bearer +
  Cline identification headers for a BYOK key — extracted to a leaf
  module to avoid growing the frozen open-sse/executors/default.ts.
- Refresh: dispatch clinepass to the shared refreshClineToken() (was
  falling through to the generic refresh and failing silently).
- Catalog: admit the BYOK path through a dedicated
  DUAL_AUTH_APIKEY_PROVIDER_IDS gate (src/lib/providers/catalog.ts) so
  POST /api/providers accepts an apikey connection without flipping
  isOAuth off (which would break the primary Connect->OAuth routing).
- Dashboard: render both "Connect" + "Manual API key" buttons for
  clinepass (ConnectionsHeaderToolbar.tsx, EmptyConnectionsPlaceholder.tsx).
- Dedup: removed the now-redundant API-key-only APIKEY_PROVIDERS_GATEWAYS
  entry so ClinePass is listed once (OAuth-primary).
- oauth.ts: added the clinepass catalog entry (was reverted by staleness
  during rebase); src/lib/oauth/providers/index.ts: clinepass -> cline.

This branch was ~167 commits / weeks behind release/v3.8.47; a real
merge surfaced 61 conflicting files, several of which are already-shipped
fixes (forceStream #6165, zed-hosted, requesty, agentrouter CC-wire-image,
NVIDIA/Mistral/kimi executor fixes, chatCore hardening) that a naive
resolution would have silently reverted. Reconstructed clean on top of
current release/v3.8.47, isolating and re-applying only the clinepass
dual-auth feature and preserving every already-shipped fix untouched.

tokenRefresh.ts's frozen-file cap raised by the irreducible 1-line
`case "clinepass":` switch label (config/quality/file-size-baseline.json,
justified inline); open-sse/executors/default.ts stays under its cap via
the buildClinepassHeaders() extraction.

Regression guard: tests/unit/clinepass-provider.test.ts (15/15, extended
with the dual-auth admission-gate and alias-consistency guards).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* test(providers): update APIKEY_PROVIDERS spread-merge count 171->170

The ClinePass dual-auth rebase (this PR) removed the now-redundant
API-key-only APIKEY_PROVIDERS_GATEWAYS.clinepass entry (dedup — clinepass
is OAuth-primary now, with its BYOK path admitted through the
DUAL_AUTH_APIKEY_PROVIDER_IDS gate instead of a second catalog entry),
which drops the total APIKEY_PROVIDERS spread-merge count by one.
tests/unit/providers-constants-split.test.ts hardcoded the prior count
(171); updated to 170 to match, confirmed via CI (Unit Tests fast-path
1/2 and 2/2 both failed on the stale count).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(oauth): register clinepass in PROVIDERS enum to fix Unknown provider error

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* chore(test): rebaseline oauth-providers-config.test.ts frozen size for clinepass entries

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(changelog): re-restore #6126 bullet after release sync

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: hajilok <hajilok@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
This commit is contained in:
Imam Wahyu Widodo
2026-07-10 06:37:40 +07:00
committed by GitHub
parent 9906dfc1ba
commit d3331f8bca
16 changed files with 191 additions and 53 deletions

View File

@@ -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 `<alias>/<modelId>` 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

View File

@@ -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,

View File

@@ -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 `<catalogAlias>/<modelId>` (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",

View File

@@ -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 <token>`

View File

@@ -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":

View File

@@ -247,17 +247,22 @@ export default function ConnectionsHeaderToolbar({
)}
{!isCompatible ? (
<>
{isCommandCode ? (
{isCommandCode || providerId === "clinepass" ? (
<>
<Button
size="sm"
icon="open_in_new"
loading={
commandCodeAuthState.phase === "starting" ||
commandCodeAuthState.phase === "polling" ||
commandCodeAuthState.phase === "applying"
isCommandCode &&
(commandCodeAuthState.phase === "starting" ||
commandCodeAuthState.phase === "polling" ||
commandCodeAuthState.phase === "applying")
}
onClick={() =>
gateConnectionFlow(
isCommandCode ? handleOpenCommandCodeConnect : openPrimaryAddFlow
)
}
onClick={() => gateConnectionFlow(handleOpenCommandCodeConnect)}
>
Connect
</Button>

View File

@@ -55,16 +55,21 @@ export default function EmptyConnectionsPlaceholder({
<p className="text-sm text-text-muted mb-4">{t("addFirstConnectionHint")}</p>
{!isCompatible && (
<div className="flex items-center justify-center gap-2">
{isCommandCode ? (
{isCommandCode || providerId === "clinepass" ? (
<>
<Button
icon="open_in_new"
loading={
commandCodeAuthState.phase === "starting" ||
commandCodeAuthState.phase === "polling" ||
commandCodeAuthState.phase === "applying"
isCommandCode &&
(commandCodeAuthState.phase === "starting" ||
commandCodeAuthState.phase === "polling" ||
commandCodeAuthState.phase === "applying")
}
onClick={() =>
gateConnectionFlow(
isCommandCode ? handleOpenCommandCodeConnect : openPrimaryAddFlow
)
}
onClick={() => gateConnectionFlow(handleOpenCommandCodeConnect)}
>
Connect
</Button>

View File

@@ -484,6 +484,7 @@ export const PROVIDERS = {
CURSOR: "cursor",
KILOCODE: "kilocode",
CLINE: "cline",
CLINEPASS: "clinepass",
WINDSURF: "windsurf",
DEVIN_CLI: "devin-cli",
TRAE: "trae",

View File

@@ -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,

View File

@@ -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<string>(["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));

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string, string> {
if (credentials?.accessToken) {
return buildClineHeaders(credentials.accessToken);
}
const headers: Record<string, string> = {
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
};
const byokKey = effectiveKey || (credentials?.apiKey as string | undefined);
if (byokKey) headers.Authorization = `Bearer ${byokKey}`;
return headers;
}

View File

@@ -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 `<catalogAlias>/<modelId>` (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 <alias>/<model> resolves to clinepass"
);
assert.equal(providerRegistry.clinepass.alias, "cp");
});

View File

@@ -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"],

View File

@@ -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/<family>.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<string, object>).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", () => {