From 26fd7b6a8a504520cde3519b341f304673d10251 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:36:16 -0300 Subject: [PATCH] feat(providers): add ClinePass API-key provider (#5942) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.44 β€” ClinePass API-key (BYOK) provider (port upstream 9router#2304, co-authored @adentdk). Validated locally: 16 clinepass tests green; fixed the APIKEY count 158β†’159 + translate-path golden snapshot (clinepass is a genuine new provider). Remaining UNSTABLE red is the pre-existing environmental setup-claude base-red (opencode-plugin dist not built in fast-path). Supersedes stub #5541. --- CHANGELOG.md | 4 +- open-sse/config/providers/index.ts | 2 + .../providers/registry/clinepass/index.ts | 42 +++++++ open-sse/executors/default.ts | 45 +++++++ open-sse/handlers/chatCore.ts | 58 +++++++++ open-sse/services/clinepassModels.ts | 76 ++++++++++++ open-sse/utils/clinepassEnvelope.ts | 59 +++++++++ open-sse/utils/error.ts | 13 +- .../models/discovery/providerModelsConfig.ts | 11 ++ .../constants/providers/apikey/gateways.ts | 14 +++ tests/snapshots/provider/translate-path.json | 29 +++++ tests/unit/clinepass-provider.test.ts | 117 ++++++++++++++++++ tests/unit/clinepass-thinking-budget.test.ts | 77 ++++++++++++ tests/unit/providers-constants-split.test.ts | 10 +- 14 files changed, 548 insertions(+), 9 deletions(-) create mode 100644 open-sse/config/providers/registry/clinepass/index.ts create mode 100644 open-sse/services/clinepassModels.ts create mode 100644 open-sse/utils/clinepassEnvelope.ts create mode 100644 tests/unit/clinepass-provider.test.ts create mode 100644 tests/unit/clinepass-thinking-budget.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b08d31be40..293f2887fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,11 @@ ### ✨ New Features -_TBD_ +- **feat(providers):** add ClinePass as a first-class API-key provider (Cline's BYOK gateway). (thanks @adentdk) ### πŸ”§ Bug Fixes -- **fix(translator):** antigravityβ†’openai request now emits Anthropic-compliant content blocks β€” drops empty text blocks and preserves tool calls/text co-located with tool results. (thanks @SahrulRamadhanHardiansyah) +_TBD_ ### πŸ“ Maintenance diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index ac9847e86d..9913c7ea1c 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -49,6 +49,7 @@ import { groqProvider } from "./registry/groq/index.ts"; import { inference_netProvider } from "./registry/inference-net/index.ts"; import { llm7Provider } from "./registry/llm7/index.ts"; import { cerebrasProvider } from "./registry/cerebras/index.ts"; +import { clinepassProvider } from "./registry/clinepass/index.ts"; import { sparkdeskProvider } from "./registry/sparkdesk/index.ts"; import { nlpcloudProvider } from "./registry/nlpcloud/index.ts"; import { nvidiaProvider } from "./registry/nvidia/index.ts"; @@ -220,6 +221,7 @@ export const REGISTRY: Record = { "inference-net": inference_netProvider, llm7: llm7Provider, cerebras: cerebrasProvider, + clinepass: clinepassProvider, sparkdesk: sparkdeskProvider, nlpcloud: nlpcloudProvider, nvidia: nvidiaProvider, diff --git a/open-sse/config/providers/registry/clinepass/index.ts b/open-sse/config/providers/registry/clinepass/index.ts new file mode 100644 index 0000000000..b91a4b75d5 --- /dev/null +++ b/open-sse/config/providers/registry/clinepass/index.ts @@ -0,0 +1,42 @@ +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. +export const clinepassProvider: RegistryEntry = { + id: "clinepass", + alias: "clinepass", + format: "openai", + executor: "default", + baseUrl: "https://api.cline.bot/api/v1/chat/completions", + authType: "apikey", + authHeader: "bearer", + extraHeaders: { + "HTTP-Referer": "https://cline.bot", + "X-Title": "Cline", + }, + models: [ + { id: "cline-pass/glm-5.2", name: "GLM-5.2 (ClinePass)" }, + { id: "cline-pass/kimi-k2.7-code", name: "Kimi K2.7 Code (ClinePass)" }, + { id: "cline-pass/kimi-k2.6", name: "Kimi K2.6 (ClinePass)" }, + { + id: "cline-pass/deepseek-v4-pro", + name: "DeepSeek V4 Pro (ClinePass)", + supportsReasoning: true, + maxOutputTokens: 50000, + }, + { + id: "cline-pass/deepseek-v4-flash", + name: "DeepSeek V4 Flash (ClinePass)", + supportsReasoning: true, + maxOutputTokens: 50000, + }, + { id: "cline-pass/mimo-v2.5", name: "MiMo-V2.5 (ClinePass)" }, + { id: "cline-pass/mimo-v2.5-pro", name: "MiMo-V2.5-Pro (ClinePass)" }, + { id: "cline-pass/minimax-m3", name: "MiniMax M3 (ClinePass)" }, + { id: "cline-pass/qwen3.7-max", name: "Qwen3.7 Max (ClinePass)" }, + { id: "cline-pass/qwen3.7-plus", name: "Qwen3.7 Plus (ClinePass)" }, + ], + passthroughModels: true, +}; diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index cbdb8d90bf..0d52630666 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -737,9 +737,54 @@ export class DefaultExecutor extends BaseExecutor { } } + // ClinePass reasoning models burn all of max_tokens on the thinking phase + // when the budget is too small, leaving content empty (finish_reason: + // "length"). Bump max_tokens to a safe floor when reasoning is enabled and + // the budget is undersized. CLINEPASS-GATED β€” no-op for every other provider. + if (typeof withDefaults === "object" && withDefaults !== null) { + this.ensureThinkingBudget(withDefaults as Record, model); + } + return withDefaults; } + // ClinePass / OpenRouter-style thinking models leave content empty when the + // reasoning budget consumes all of max_tokens. Bump max_tokens to a safe + // minimum only when reasoning is enabled and the budget is undersized. + // CLINEPASS-GATED: returns early for every other provider. + ensureThinkingBudget(body: Record, model: string): Record { + if (!body || this.provider !== "clinepass") return body; + + const outboundModel = typeof body.model === "string" ? body.model : model; + const entry = getRegistryEntry(this.provider); + const modelEntry = entry?.models?.find((m) => m.id === outboundModel); + if (!modelEntry?.supportsReasoning) return body; + + const extraBody = body.extra_body as Record | undefined; + const thinking = extraBody?.thinking as Record | undefined; + const effort = body.reasoning_effort; + const reasoningEnabled = + thinking?.type === "enabled" || + (typeof effort === "string" && effort !== "none" && effort !== "off") || + effort === true; + if (!reasoningEnabled) return body; + + const MIN_TOKENS = 4096; + const maxOutput = + typeof modelEntry.maxOutputTokens === "number" && modelEntry.maxOutputTokens > 0 + ? modelEntry.maxOutputTokens + : MIN_TOKENS; + const target = Math.min(MIN_TOKENS, maxOutput); + const current = body.max_tokens ?? body.max_completion_tokens; + + if (typeof current !== "number" || current <= 0) { + body.max_tokens = target; + } else if (current < MIN_TOKENS && current < maxOutput) { + body.max_tokens = MIN_TOKENS; + } + return body; + } + /** * Refresh credentials via the centralized tokenRefresh service. * Delegates to getAccessToken() which handles all providers with diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 4535402de7..23958514e7 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -212,6 +212,7 @@ import { type NonStreamingSseTerminalState, } from "./chatCore/nonStreamingSse.ts"; import { parseNonStreamingResponseBody } from "./chatCore/nonStreamingResponseParse.ts"; +import { unwrapClinepassEnvelope } from "../utils/clinepassEnvelope.ts"; import { recordNonStreamingUsageStats } from "./chatCore/nonStreamingUsageStats.ts"; import { createBodyTimeoutError, @@ -3517,6 +3518,63 @@ export async function handleChatCore({ let responseBody = parsed.responseBody; let responsePayloadFormat = parsed.responsePayloadFormat; + // ── ClinePass {success,data} envelope unwrap (before translation) ────────── + // ClinePass wraps non-streaming JSON in a {success, data} envelope; errors + // use {success:false, error}. Transient {success:false, error:"empty..."} + // responses get one 2s retry before surfacing. CLINEPASS-GATED β€” untouched + // for every other provider. Envelope errors route through createErrorResult + // (β†’ buildErrorBody/sanitizeErrorMessage, Rule #12). + if (provider === "clinepass") { + let { body: unwrapped, error: envError } = unwrapClinepassEnvelope(responseBody, provider); + if (envError && /empty/i.test(envError.message || "")) { + log?.warn?.("RETRY", "clinepass returned empty content, retrying once after 2s"); + await new Promise((r) => setTimeout(r, 2000)); + try { + const retryResult = await executeProviderRequest(effectiveModel, false); + if (retryResult?.response?.ok) { + const retryParsed = await parseNonStreamingResponseBody({ + providerResponse: retryResult.response, + upstreamStream: undefined, + providerHeaders: retryResult.headers, + finalBody: retryResult.transformedBody, + targetFormat, + model, + log, + }); + if (retryParsed.kind !== "invalid_sse" && retryParsed.kind !== "invalid_json") { + providerResponse = retryResult.response; + providerUrl = retryResult.url; + providerHeaders = retryResult.headers; + finalBody = providerRequestCapture.body(retryResult.transformedBody); + ({ body: unwrapped, error: envError } = unwrapClinepassEnvelope( + retryParsed.responseBody, + provider + )); + } + } + } catch (retryErr) { + log?.warn?.( + "RETRY", + `clinepass retry failed: ${ + retryErr instanceof Error ? retryErr.message : String(retryErr) + }` + ); + } + } + if (envError) { + appendRequestLog({ + model, + provider, + connectionId, + status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`, + }).catch(() => {}); + persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "clinepass_envelope_error"); + trackPendingRequest(model, provider, connectionId, false); + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, envError.message); + } + responseBody = unwrapped; + } + // Check for empty content response (fake success) - trigger fallback if (isEmptyContentResponse(responseBody)) { appendRequestLog({ diff --git a/open-sse/services/clinepassModels.ts b/open-sse/services/clinepassModels.ts new file mode 100644 index 0000000000..ee25f6e8e5 --- /dev/null +++ b/open-sse/services/clinepassModels.ts @@ -0,0 +1,76 @@ +import { buildClineHeaders } from "@/shared/utils/clineAuth"; + +// ClinePass live-models resolver. ClinePass is API-key-only (BYOK), but the +// underlying api.cline.bot host also accepts the OAuth `cline` credential shape, +// so the resolver reuses buildClineHeaders() (the shared workos:-prefixed Cline +// header set) for the non-apikey path. Only `cline-pass/*` model ids are kept. + +const CLINEPASS_MODELS_ENDPOINT = "https://api.cline.bot/api/v1/models"; +const FETCH_TIMEOUT_MS = 5000; + +export interface ClinepassModel { + id: string; + name: string; +} + +/** + * Filter a raw models list down to the ClinePass namespace (`cline-pass/*`). + * Pure β€” shared by the live resolver and the discovery-config parseResponse. + */ +export function filterClinepassModels(rawList: unknown): ClinepassModel[] { + if (!Array.isArray(rawList)) return []; + return rawList + .filter( + (m): m is { id: string; name?: string } => + !!m && + typeof (m as { id?: unknown }).id === "string" && + (m as { id: string }).id.startsWith("cline-pass/") + ) + .map((m) => ({ id: m.id, name: m.name || m.id })); +} + +function buildModelListHeaders(token: string, isApiKey: boolean): Record { + if (isApiKey) { + return { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }; + } + return buildClineHeaders(token, { Accept: "application/json" }); +} + +/** + * Resolve the live ClinePass model catalogue for a connection. Returns + * `{ models }` on success or `null` on any failure (missing token, non-2xx, + * bad shape, timeout) so callers fall back to the static registry catalogue. + */ +export async function resolveClinepassModels(credentials: { + apiKey?: string | null; + accessToken?: string | null; +}): Promise<{ models: ClinepassModel[] } | null> { + const isApiKey = Boolean(credentials?.apiKey); + const token = isApiKey ? credentials.apiKey : credentials?.accessToken; + if (!token) return null; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const headers = buildModelListHeaders(token, isApiKey); + const response = await fetch(CLINEPASS_MODELS_ENDPOINT, { + method: "GET", + headers, + signal: controller.signal, + }); + if (!response.ok) return null; + + const json = await response.json(); + const rawList = Array.isArray(json) ? json : json?.data; + const models = filterClinepassModels(rawList); + return models.length ? { models } : null; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} diff --git a/open-sse/utils/clinepassEnvelope.ts b/open-sse/utils/clinepassEnvelope.ts new file mode 100644 index 0000000000..925bb4efeb --- /dev/null +++ b/open-sse/utils/clinepassEnvelope.ts @@ -0,0 +1,59 @@ +// ClinePass upstream wraps non-streaming JSON responses in a {success, data} +// envelope (errors use {success: false, error}). Detect and unwrap; pass the +// payload through untouched for every other provider / shape. + +export interface ClinepassEnvelopeError { + message: string; + status: number | null; +} + +export interface ClinepassEnvelopeResult { + body: unknown; + error: ClinepassEnvelopeError | null; +} + +/** + * Unwrap a ClinePass {success, data} envelope. + * + * - Non-clinepass provider, non-object, array, or object without a `success` + * key β†’ pass through untouched ({ body, error: null }). + * - { success: false, ... } β†’ { body: null, error: { message, status } } with + * the upstream error string extracted (never a local stack β€” the caller must + * still route it through sanitizeErrorMessage before emitting a response). + * - { success: true, data: {...} } β†’ unwrap to `data`. + */ +export function unwrapClinepassEnvelope( + body: unknown, + provider: string | null | undefined +): ClinepassEnvelopeResult { + if (provider !== "clinepass") return { body, error: null }; + if (!body || typeof body !== "object" || Array.isArray(body)) return { body, error: null }; + + const record = body as Record; + if (!("success" in record)) return { body, error: null }; + + if (record.success === false) { + const rawError = record.error; + const message = + typeof rawError === "string" + ? rawError + : (rawError && typeof rawError === "object" + ? ((rawError as Record).message as string | undefined) + : undefined) || + (typeof record.message === "string" ? record.message : undefined) || + "Upstream error"; + const statusCode = typeof record.statusCode === "number" ? record.statusCode : null; + return { body: null, error: { message, status: statusCode } }; + } + + if ( + record.success === true && + "data" in record && + record.data !== null && + typeof record.data === "object" + ) { + return { body: record.data, error: null }; + } + + return { body, error: null }; +} diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index 17bbef99d4..9afc237715 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -1,4 +1,5 @@ import { CORS_HEADERS } from "./cors.ts"; +import { unwrapClinepassEnvelope } from "./clinepassEnvelope.ts"; import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts"; import { normalizePayloadForLog } from "@/lib/logPayloads"; import type { ModelCooldownErrorPayload } from "@/types"; @@ -230,7 +231,14 @@ export async function parseUpstreamError(response: Response, provider: string | const parsed = JSON.parse(text); // Handle array responses (e.g., from some Gemini APIs) const json = (Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : parsed) || {}; - message = json.error?.message || json.message || json.error || text; + // ClinePass wraps upstream errors in a {success:false, error} envelope. + // Extract the upstream error string (an upstream JSON field, not a local + // stack) β€” still routed through sanitizeErrorMessage/buildErrorBody by + // every consumer below (Rule #12). + const { error: clinepassEnvError } = unwrapClinepassEnvelope(json, provider); + message = clinepassEnvError + ? clinepassEnvError.message + : json.error?.message || json.message || json.error || text; errorCode = json.error?.code || json.code; errorType = json.error?.type || json.type; } catch { @@ -497,7 +505,8 @@ export function formatProviderError( const message = error.message || "Unknown error"; // Expose low-level cause (e.g. UND_ERR_SOCKET, ECONNRESET, ETIMEDOUT) for diagnosing fetch failures const cause = (error as { cause?: unknown }).cause; - const causeObj = cause && typeof cause === "object" ? (cause as Record) : undefined; + const causeObj = + cause && typeof cause === "object" ? (cause as Record) : undefined; const causeCode = typeof causeObj?.code === "string" ? causeObj.code : undefined; const causeMsg = typeof causeObj?.message === "string" ? causeObj.message : undefined; const causeStr = diff --git a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts index ab13717427..4cffe5772b 100644 --- a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts +++ b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts @@ -1,6 +1,7 @@ import { getAntigravityModelsDiscoveryUrls } from "@omniroute/open-sse/config/antigravityUpstream.ts"; import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts"; import { parseGeminiModelsList } from "@/lib/providerModels/geminiModelsParser"; +import { filterClinepassModels } from "@omniroute/open-sse/services/clinepassModels.ts"; import { normalizeOpenAiLikeModelsResponse } from "./normalizers"; export type ProviderModelsConfigEntry = { @@ -246,6 +247,16 @@ export const PROVIDER_MODELS_CONFIG: Record = authPrefix: "Bearer ", parseResponse: (data) => data.data || data.models || [], }, + // ClinePass (BYOK apikey gateway) β€” same host as OAuth `cline`, but only the + // `cline-pass/*` namespace is surfaced (filterClinepassModels). + clinepass: { + url: "https://api.cline.bot/api/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => filterClinepassModels(Array.isArray(data) ? data : data?.data), + }, cohere: { url: "https://api.cohere.com/v2/models", method: "GET", diff --git a/src/shared/constants/providers/apikey/gateways.ts b/src/shared/constants/providers/apikey/gateways.ts index 0fca742cb5..540805bf54 100644 --- a/src/shared/constants/providers/apikey/gateways.ts +++ b/src/shared/constants/providers/apikey/gateways.ts @@ -28,6 +28,20 @@ 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/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 3dfce0ca56..f64cee4d49 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -772,6 +772,35 @@ "stream": "https://api.cline.bot/api/v1/chat/completions" } }, + "clinepass": { + "format": "openai", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://cline.bot", + "X-Title": "Cline" + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://cline.bot", + "X-Title": "Cline" + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://cline.bot", + "X-Title": "Cline" + } + }, + "url": { + "nonStream": "https://api.cline.bot/api/v1/chat/completions", + "stream": "https://api.cline.bot/api/v1/chat/completions" + } + }, "cloudflare-ai": { "format": "openai", "headers": { diff --git a/tests/unit/clinepass-provider.test.ts b/tests/unit/clinepass-provider.test.ts new file mode 100644 index 0000000000..1706e1cc56 --- /dev/null +++ b/tests/unit/clinepass-provider.test.ts @@ -0,0 +1,117 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { APIKEY_PROVIDERS } = await import("../../src/shared/constants/providers.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"); + 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" + ); +}); + +test("ClinePass registry entry uses OpenAI format with bearer apikey auth + 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.authHeader, "bearer"); + 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"); +}); + +test("ClinePass models are cline-pass/* and deepseek entries flag reasoning", () => { + const models = providerRegistry.clinepass.models; + const ids = models.map((m: { id: string }) => m.id); + assert.ok(ids.length >= 8, "expect a non-trivial seed list"); + assert.equal(new Set(ids).size, ids.length, "model ids must be unique"); + for (const id of ids) { + assert.ok(id.startsWith("cline-pass/"), `${id} must be in the cline-pass/ namespace`); + } + const deepseek = models.filter((m: { id: string }) => m.id.includes("deepseek")); + assert.ok(deepseek.length >= 2, "expect the two DeepSeek V4 entries"); + for (const m of deepseek) { + assert.equal((m as { supportsReasoning?: boolean }).supportsReasoning, true); + } +}); + +// ── Envelope unwrap ────────────────────────────────────────────────────────── +test("unwrapClinepassEnvelope: success unwraps to data", () => { + const inner = { id: "chatcmpl-1", choices: [] }; + const { body, error } = unwrapClinepassEnvelope({ success: true, data: inner }, "clinepass"); + assert.equal(error, null); + assert.deepEqual(body, inner); +}); + +test("unwrapClinepassEnvelope: {success:false} yields an error", () => { + const { body, error } = unwrapClinepassEnvelope( + { success: false, error: "empty response content", statusCode: 502 }, + "clinepass" + ); + assert.equal(body, null); + assert.ok(error); + assert.equal(error?.message, "empty response content"); + assert.equal(error?.status, 502); +}); + +test("unwrapClinepassEnvelope: nested error.message extracted", () => { + const { error } = unwrapClinepassEnvelope( + { success: false, error: { message: "quota exceeded" } }, + "clinepass" + ); + assert.equal(error?.message, "quota exceeded"); +}); + +test("unwrapClinepassEnvelope: non-clinepass provider passes through untouched", () => { + const payload = { success: false, error: "boom" }; + const { body, error } = unwrapClinepassEnvelope(payload, "openai"); + assert.equal(error, null); + assert.deepEqual(body, payload); +}); + +test("unwrapClinepassEnvelope: non-object / array / no-success passthrough", () => { + assert.deepEqual(unwrapClinepassEnvelope("plain", "clinepass"), { body: "plain", error: null }); + assert.deepEqual(unwrapClinepassEnvelope([1, 2], "clinepass"), { body: [1, 2], error: null }); + const bare = { id: "x" }; + assert.deepEqual(unwrapClinepassEnvelope(bare, "clinepass"), { body: bare, error: null }); +}); + +// ── Model filter ───────────────────────────────────────────────────────────── +test("filterClinepassModels keeps only cline-pass/* ids", () => { + const out = filterClinepassModels([ + { id: "cline-pass/glm-5.2", name: "GLM" }, + { id: "openai/gpt-5.5" }, + { id: "cline-pass/deepseek-v4-pro" }, + { notId: true }, + ]); + assert.deepEqual(out, [ + { id: "cline-pass/glm-5.2", name: "GLM" }, + { id: "cline-pass/deepseek-v4-pro", name: "cline-pass/deepseek-v4-pro" }, + ]); + assert.deepEqual(filterClinepassModels("not-array"), []); +}); + +// ── Error sanitization (Rule #12 β€” no stack leak) ──────────────────────────── +test("parseUpstreamError unwraps clinepass envelope error without leaking a stack", async () => { + const upstream = new Response( + JSON.stringify({ success: false, error: "upstream at /srv/x.js:1:1 failed" }), + { status: 502, headers: { "content-type": "application/json" } } + ); + const parsed = await parseUpstreamError(upstream, "clinepass"); + 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"); +}); diff --git a/tests/unit/clinepass-thinking-budget.test.ts b/tests/unit/clinepass-thinking-budget.test.ts new file mode 100644 index 0000000000..db49bac583 --- /dev/null +++ b/tests/unit/clinepass-thinking-budget.test.ts @@ -0,0 +1,77 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { DefaultExecutor } from "../../open-sse/executors/default.ts"; + +// DefaultExecutor.ensureThinkingBudget β€” clinepass-gated max_tokens floor for +// reasoning models (prevents empty content when the budget is undersized). + +test("bumps undersized max_tokens to 4096 for a clinepass reasoning model", () => { + const executor = new DefaultExecutor("clinepass"); + const body = { + model: "cline-pass/deepseek-v4-pro", + reasoning_effort: "high", + max_tokens: 512, + } as Record; + + executor.ensureThinkingBudget(body, "cline-pass/deepseek-v4-pro"); + assert.equal(body.max_tokens, 4096); +}); + +test("sets max_tokens floor when absent for a reasoning model", () => { + const executor = new DefaultExecutor("clinepass"); + const body = { + model: "cline-pass/deepseek-v4-flash", + reasoning_effort: "medium", + } as Record; + + executor.ensureThinkingBudget(body, "cline-pass/deepseek-v4-flash"); + assert.equal(body.max_tokens, 4096); +}); + +test("leaves an already-sufficient budget untouched", () => { + const executor = new DefaultExecutor("clinepass"); + const body = { + model: "cline-pass/deepseek-v4-pro", + reasoning_effort: "high", + max_tokens: 8000, + } as Record; + + executor.ensureThinkingBudget(body, "cline-pass/deepseek-v4-pro"); + assert.equal(body.max_tokens, 8000); +}); + +test("no-op when reasoning is disabled", () => { + const executor = new DefaultExecutor("clinepass"); + const body = { + model: "cline-pass/deepseek-v4-pro", + max_tokens: 100, + } as Record; + + executor.ensureThinkingBudget(body, "cline-pass/deepseek-v4-pro"); + assert.equal(body.max_tokens, 100); +}); + +test("no-op for a non-reasoning clinepass model", () => { + const executor = new DefaultExecutor("clinepass"); + const body = { + model: "cline-pass/glm-5.2", + reasoning_effort: "high", + max_tokens: 100, + } as Record; + + executor.ensureThinkingBudget(body, "cline-pass/glm-5.2"); + assert.equal(body.max_tokens, 100); +}); + +test("no-op for a non-clinepass provider (gate)", () => { + const executor = new DefaultExecutor("openrouter"); + const body = { + model: "cline-pass/deepseek-v4-pro", + reasoning_effort: "high", + max_tokens: 100, + } as Record; + + executor.ensureThinkingBudget(body, "cline-pass/deepseek-v4-pro"); + assert.equal(body.max_tokens, 100); +}); diff --git a/tests/unit/providers-constants-split.test.ts b/tests/unit/providers-constants-split.test.ts index eb4e425673..601d03f72e 100644 --- a/tests/unit/providers-constants-split.test.ts +++ b/tests/unit/providers-constants-split.test.ts @@ -31,12 +31,12 @@ test("barrel still exports every catalog + key helpers", () => { } }); -test("APIKEY_PROVIDERS merges the 6 family files into 158 entries (no loss / no dup)", async () => { +test("APIKEY_PROVIDERS merges the 6 family files into 159 entries (no loss / no dup)", async () => { const keys = Object.keys((P as Record).APIKEY_PROVIDERS); - assert.equal(keys.length, 158); - assert.equal(new Set(keys).size, 158, "duplicate keys after spread-merge"); + assert.equal(keys.length, 159); + assert.equal(new Set(keys).size, 159, "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 158. + // strict partition (every provider in exactly one), so the sum must be exactly 159. const families: [string, string][] = [ ["gateways", "APIKEY_PROVIDERS_GATEWAYS"], ["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"], @@ -56,7 +56,7 @@ test("APIKEY_PROVIDERS merges the 6 family files into 158 entries (no loss / no seen.add(k); } } - assert.equal(famTotal, 158, "families must partition all 158 providers"); + assert.equal(famTotal, 159, "families must partition all 159 providers"); }); test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => {