Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

### ✨ New Features

_TBD_
- **feat(providers):** add ClinePass as a first-class API-key provider (Cline's BYOK gateway). (thanks @adentdk)

### 🔧 Bug Fixes

Expand Down
2 changes: 2 additions & 0 deletions open-sse/config/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -220,6 +221,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
"inference-net": inference_netProvider,
llm7: llm7Provider,
cerebras: cerebrasProvider,
clinepass: clinepassProvider,
sparkdesk: sparkdeskProvider,
nlpcloud: nlpcloudProvider,
nvidia: nvidiaProvider,
Expand Down
42 changes: 42 additions & 0 deletions open-sse/config/providers/registry/clinepass/index.ts
Original file line number Diff line number Diff line change
@@ -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,
};
45 changes: 45 additions & 0 deletions open-sse/executors/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>, 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<string, unknown>, model: string): Record<string, unknown> {
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<string, unknown> | undefined;
const thinking = extraBody?.thinking as Record<string, unknown> | 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;
}
Comment on lines +778 to +784

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In ensureThinkingBudget, unconditionally setting body.max_tokens can conflict with recent OpenAI models (or other models) where max_tokens has already been mapped/renamed to max_completion_tokens earlier in transformRequest (e.g., at line 680). This re-introduces max_tokens alongside max_completion_tokens, which can cause the upstream API to reject the request or ignore the parameter.

We should detect which parameter is currently present in the request body and update/set that specific parameter instead.

Suggested change
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;
}
const current = body.max_tokens ?? body.max_completion_tokens;
const key = ("max_completion_tokens" in body) ? "max_completion_tokens" : "max_tokens";
if (typeof current !== "number" || current <= 0) {
body[key] = target;
} else if (current < MIN_TOKENS && current < maxOutput) {
body[key] = MIN_TOKENS;
}

return body;
}

/**
* Refresh credentials via the centralized tokenRefresh service.
* Delegates to getAccessToken() which handles all providers with
Expand Down
58 changes: 58 additions & 0 deletions open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
));
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If the ClinePass empty-content retry request fails with a non-2xx status (e.g., 401 Unauthorized or 429 Rate Limited), the non-ok response is currently ignored because we only check if (retryResult?.response?.ok). This results in returning a 502 Bad Gateway with the original "empty response content" error message, completely masking the actual retry failure and failing to update key health status or log the actual failure status.

We should handle the non-ok retry response by parsing the upstream error, updating key health if it's a 401, logging the failure, and returning the correct error status and message.

          } else if (retryResult?.response) {
            const retryErrorDetails = await parseUpstreamError(retryResult.response, provider);
            const retryStatusCode = retryErrorDetails.statusCode;
            const retryMessage = formatProviderError(new Error(retryErrorDetails.message), provider, model, retryStatusCode);
            appendRequestLog({ 
              model,
              provider,
              connectionId,
              status: `FAILED ${retryStatusCode}`,
            }).catch(() => {});
            if (retryStatusCode === 401 && credentials) {
              recordKeyHealthStatus(401, getExecutionCredentials());
            }
            persistFailureUsage(retryStatusCode, "clinepass_retry_failed");
            trackPendingRequest(model, provider, connectionId, false);
            return createErrorResult(retryStatusCode, retryMessage);
          }

} 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({
Expand Down
76 changes: 76 additions & 0 deletions open-sse/services/clinepassModels.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
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);
}
}
59 changes: 59 additions & 0 deletions open-sse/utils/clinepassEnvelope.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>).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 };
}
13 changes: 11 additions & 2 deletions open-sse/utils/error.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown>) : undefined;
const causeObj =
cause && typeof cause === "object" ? (cause as Record<string, unknown>) : undefined;
const causeCode = typeof causeObj?.code === "string" ? causeObj.code : undefined;
const causeMsg = typeof causeObj?.message === "string" ? causeObj.message : undefined;
const causeStr =
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -246,6 +247,16 @@ export const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> =
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",
Expand Down
Loading
Loading