mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
feat(providers): add ClinePass API-key provider (#5942)
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.
This commit is contained in:
committed by
GitHub
parent
2b0da37c19
commit
26fd7b6a8a
@@ -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
|
||||
|
||||
|
||||
@@ -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<string, RegistryEntry> = {
|
||||
"inference-net": inference_netProvider,
|
||||
llm7: llm7Provider,
|
||||
cerebras: cerebrasProvider,
|
||||
clinepass: clinepassProvider,
|
||||
sparkdesk: sparkdeskProvider,
|
||||
nlpcloud: nlpcloudProvider,
|
||||
nvidia: nvidiaProvider,
|
||||
|
||||
42
open-sse/config/providers/registry/clinepass/index.ts
Normal file
42
open-sse/config/providers/registry/clinepass/index.ts
Normal file
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh credentials via the centralized tokenRefresh service.
|
||||
* Delegates to getAccessToken() which handles all providers with
|
||||
|
||||
@@ -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({
|
||||
|
||||
76
open-sse/services/clinepassModels.ts
Normal file
76
open-sse/services/clinepassModels.ts
Normal file
@@ -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
open-sse/utils/clinepassEnvelope.ts
Normal file
59
open-sse/utils/clinepassEnvelope.ts
Normal file
@@ -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 };
|
||||
}
|
||||
@@ -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<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 =
|
||||
|
||||
@@ -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<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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -772,6 +772,35 @@
|
||||
"stream": "https://api.cline.bot/api/v1/chat/completions"
|
||||
}
|
||||
},
|
||||
"clinepass": {
|
||||
"format": "openai",
|
||||
"headers": {
|
||||
"apiKey": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://cline.bot",
|
||||
"X-Title": "Cline"
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://cline.bot",
|
||||
"X-Title": "Cline"
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"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": {
|
||||
|
||||
117
tests/unit/clinepass-provider.test.ts
Normal file
117
tests/unit/clinepass-provider.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
77
tests/unit/clinepass-thinking-budget.test.ts
Normal file
77
tests/unit/clinepass-thinking-budget.test.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
|
||||
executor.ensureThinkingBudget(body, "cline-pass/deepseek-v4-pro");
|
||||
assert.equal(body.max_tokens, 100);
|
||||
});
|
||||
@@ -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<string, object>).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", () => {
|
||||
|
||||
Reference in New Issue
Block a user