Repository navigation
feat(providers): add ClinePass API-key provider #5942
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
| )); | ||
| } | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the ClinePass empty-content retry request fails with a non-2xx status (e.g., We should handle the non-ok retry response by parsing the upstream error, updating key health if it's a } 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({ | ||
|
|
||
| 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); | ||
| } | ||
| } |
| 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 }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In
ensureThinkingBudget, unconditionally settingbody.max_tokenscan conflict with recent OpenAI models (or other models) wheremax_tokenshas already been mapped/renamed tomax_completion_tokensearlier intransformRequest(e.g., at line 680). This re-introducesmax_tokensalongsidemax_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.