@tejasa97/pi-cline-free-provider 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tejasa97
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # @tejasa97/pi-cline-free-provider
2
+
3
+ Pi provider for Cline's **free** `z-ai/*` gateway models — including **GLM 5.3 Flash** — through the same Cline API the CLI uses.
4
+
5
+ This complements [`pi-clinepass-provider`](https://www.npmjs.com/package/pi-clinepass-provider), which exposes paid ClinePass `cline-pass/*` models. Cline CLI users already get `z-ai/glm-5.3-flash` for free; this package brings that into Pi.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pi install npm:@tejasa97/pi-cline-free-provider
11
+ ```
12
+
13
+ Or from git:
14
+
15
+ ```sh
16
+ pi install git:github.com/tejasa97/pi-cline-free-provider
17
+ ```
18
+
19
+ Local clone:
20
+
21
+ ```sh
22
+ pi install /absolute/path/to/pi-cline-free-provider
23
+ ```
24
+
25
+ After install, add your preferred model to `enabledModels` in `~/.pi/agent/settings.json` if you use that whitelist:
26
+
27
+ ```json
28
+ "clinefree/z-ai/glm-5.3-flash"
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ 1. Sign in with the Cline CLI: `cline auth`
34
+ 2. In Pi: `/login` → **Cline Free** (optional if CLI auth is already present)
35
+ 3. Pick a model: `/model clinefree/z-ai/glm-5.3-flash`
36
+
37
+ One-liner:
38
+
39
+ ```sh
40
+ pi --model clinefree/z-ai/glm-5.3-flash
41
+ ```
42
+
43
+ ### Auth
44
+
45
+ The provider reuses credentials from, in order:
46
+
47
+ - `CLINE_API_KEY` env var
48
+ - Cline CLI `~/.cline/data/settings/providers.json` (from `cline auth`)
49
+ - Pi `~/.pi/agent/auth.json` (`clinefree` or `clinepass` OAuth)
50
+
51
+ If you already use `pi-clinepass-provider`, your existing Cline CLI login works here too.
52
+
53
+ ### Static fallback models
54
+
55
+ When `/api/v1/models` is unavailable, these are registered:
56
+
57
+ | Model ID | Name |
58
+ | --- | --- |
59
+ | `z-ai/glm-5.3-flash` | GLM 5.3 Flash (Cline free) |
60
+ | `z-ai/glm-5.2:free` | GLM 5.2 Free |
61
+ | `z-ai/glm-4.7-flash` | GLM 4.7 Flash |
62
+
63
+ When authenticated, the extension dynamically discovers all `z-ai/*` models from Cline's API (excluding batch and alias entries).
64
+
65
+ ## Testing
66
+
67
+ ```sh
68
+ npm test
69
+ npm run test:e2e # requires pi + local install
70
+ ```
71
+
72
+ ## Related
73
+
74
+ - [Cline CLI](https://docs.cline.bot/)
75
+ - [pi-clinepass-provider](https://www.npmjs.com/package/pi-clinepass-provider) — paid ClinePass `cline-pass/*` catalog
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@tejasa97/pi-cline-free-provider",
3
+ "version": "0.1.0",
4
+ "description": "Pi provider for Cline's free z-ai gateway models (GLM 5.3 Flash, GLM 5.2 Free, and more) via the Cline CLI subscription",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "main": "src/index.ts",
10
+ "types": "src/index.ts",
11
+ "files": [
12
+ "src",
13
+ "tests",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --import tsx --test tests/**/*.test.ts",
19
+ "test:e2e": "bash tests/e2e/smoke.sh",
20
+ "typecheck": "tsc --noEmit"
21
+ },
22
+ "keywords": [
23
+ "ai",
24
+ "cline",
25
+ "cline-cli",
26
+ "glm",
27
+ "llm",
28
+ "pi",
29
+ "pi-coding-agent",
30
+ "pi-package",
31
+ "provider",
32
+ "z-ai"
33
+ ],
34
+ "license": "MIT",
35
+ "author": "tejasa97",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/tejasa97/pi-cline-free-provider.git"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/tejasa97/pi-cline-free-provider/issues"
42
+ },
43
+ "homepage": "https://github.com/tejasa97/pi-cline-free-provider#readme",
44
+ "pi": {
45
+ "extensions": [
46
+ "./src/index.ts"
47
+ ]
48
+ },
49
+ "peerDependencies": {
50
+ "@earendil-works/pi-ai": "*",
51
+ "@earendil-works/pi-coding-agent": "*"
52
+ },
53
+ "devDependencies": {
54
+ "@earendil-works/pi-ai": "^0.84.0",
55
+ "@earendil-works/pi-coding-agent": "^0.84.0",
56
+ "@types/node": "^26.0.1",
57
+ "tsx": "^4.20.5",
58
+ "typescript": "^7.0.0"
59
+ }
60
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { isRecord, stringValue } from "./utils.js";
2
+ import { ENV_API_KEY, WORKOS_TOKEN_PREFIX } from "./env.js";
3
+ import { walkAuthPaths, walkClineProviderSettings, type AuthKeyOptions } from "./config-store.js";
4
+
5
+ export {
6
+ defaultAuthPaths,
7
+ walkAuthPaths,
8
+ walkClineProviderSettings,
9
+ type AuthKeyOptions,
10
+ } from "./config-store.js";
11
+
12
+ const PI_AUTH_FIELDS = ["clinefree", "clinepass"] as const;
13
+
14
+ function resolveClineProvidersKey(parsed: Record<string, unknown>): string | undefined {
15
+ return walkClineProviderSettings(parsed, (settings) => stringValue(settings.apiKey));
16
+ }
17
+
18
+ function resolvePiProviderApiKey(parsed: Record<string, unknown>): string | undefined {
19
+ for (const field of PI_AUTH_FIELDS) {
20
+ const value = parsed[field];
21
+ if (typeof value === "string") return value;
22
+ if (!isRecord(value)) continue;
23
+
24
+ const access = stringValue(value.access);
25
+ if (access && !access.startsWith(WORKOS_TOKEN_PREFIX)) return access;
26
+ }
27
+ return undefined;
28
+ }
29
+
30
+ export function resolveApiKey(
31
+ providedKey?: string,
32
+ options: AuthKeyOptions = {},
33
+ ): string | undefined {
34
+ if (providedKey) return providedKey;
35
+
36
+ const env = options.env ?? process.env;
37
+ if (env[ENV_API_KEY]) return env[ENV_API_KEY];
38
+
39
+ return walkAuthPaths(options, (parsed) => {
40
+ const clineKey = resolveClineProvidersKey(parsed);
41
+ if (clineKey) return clineKey;
42
+
43
+ const apiKey = stringValue(parsed.apiKey);
44
+ if (apiKey) return apiKey;
45
+
46
+ return resolvePiProviderApiKey(parsed);
47
+ });
48
+ }
@@ -0,0 +1,77 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { isRecord } from "./utils.js";
5
+
6
+ function isMissingAuthFileError(error: unknown): boolean {
7
+ if (typeof error === "object" && error !== null && "code" in error) {
8
+ const code = (error as { code?: unknown }).code;
9
+ if (code === "ENOENT") return true;
10
+ }
11
+ const msg = error instanceof Error ? error.message : String(error);
12
+ return msg.includes("ENOENT") || msg.includes("not found");
13
+ }
14
+
15
+ export interface AuthKeyOptions {
16
+ env?: Record<string, string | undefined>;
17
+ authPaths?: readonly string[];
18
+ homeDir?: () => string;
19
+ readFile?: (path: string) => string;
20
+ fileExists?: (path: string) => boolean;
21
+ }
22
+
23
+ export function defaultAuthPaths(home: string): string[] {
24
+ return [
25
+ join(home, ".cline", "data", "settings", "providers.json"),
26
+ join(home, ".pi", "agent", "auth.json"),
27
+ ];
28
+ }
29
+
30
+ export function walkAuthPaths<T>(
31
+ options: AuthKeyOptions,
32
+ extract: (parsed: Record<string, unknown>) => T | undefined,
33
+ ): T | undefined {
34
+ const home = options.homeDir?.() ?? homedir();
35
+ const authPaths = options.authPaths ?? defaultAuthPaths(home);
36
+ const readFile = options.readFile ?? ((p: string) => readFileSync(p, "utf-8"));
37
+ const fileExists = options.fileExists ?? ((p: string) => existsSync(p));
38
+
39
+ for (const authPath of authPaths) {
40
+ let parsed: unknown;
41
+ try {
42
+ if (!fileExists(authPath)) continue;
43
+ parsed = JSON.parse(readFile(authPath));
44
+ } catch (e) {
45
+ if (!isMissingAuthFileError(e)) {
46
+ const msg = e instanceof Error ? e.message : String(e);
47
+ console.warn(`[clinefree] Warning: failed to read auth file ${authPath}: ${msg}`);
48
+ }
49
+ continue;
50
+ }
51
+
52
+ if (!isRecord(parsed)) continue;
53
+
54
+ const result = extract(parsed);
55
+ if (result !== undefined) return result;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ export function walkClineProviderSettings<T>(
61
+ parsed: Record<string, unknown>,
62
+ extract: (settings: Record<string, unknown>) => T | undefined,
63
+ ): T | undefined {
64
+ if (!isRecord(parsed)) return undefined;
65
+ const providers = isRecord(parsed.providers) ? parsed.providers : undefined;
66
+ if (!providers) return undefined;
67
+
68
+ for (const key of ["cline-pass", "cline"]) {
69
+ const provider = isRecord(providers[key]) ? providers[key] : undefined;
70
+ if (!provider) continue;
71
+ const settings = isRecord(provider.settings) ? provider.settings : undefined;
72
+ if (!settings) continue;
73
+ const result = extract(settings);
74
+ if (result !== undefined) return result;
75
+ }
76
+ return undefined;
77
+ }
package/src/env.ts ADDED
@@ -0,0 +1,26 @@
1
+ export const DEFAULT_API_BASE = "https://api.cline.bot";
2
+ export const ENV_API_KEY = "CLINE_API_KEY";
3
+ export const WORKOS_TOKEN_PREFIX = "workos:";
4
+ export const PROVIDER_NAME = "clinefree";
5
+
6
+ export function resolveApiBase(env: Record<string, string | undefined> = process.env): string {
7
+ const base = env.CLINE_API_BASE?.trim();
8
+ if (!base) return DEFAULT_API_BASE;
9
+ return base.replace(/\/+$/, "");
10
+ }
11
+
12
+ const CONTROL_CHARS_RE = new RegExp(
13
+ `[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}]`,
14
+ "g",
15
+ );
16
+
17
+ export function sanitizeApiKey(input: string): string {
18
+ const esc = "\x1b";
19
+ return input
20
+ .replaceAll(`${esc}[200~`, "")
21
+ .replaceAll(`${esc}[201~`, "")
22
+ .replaceAll("[200~", "")
23
+ .replaceAll("[201~", "")
24
+ .replace(CONTROL_CHARS_RE, "")
25
+ .trim();
26
+ }
@@ -0,0 +1,32 @@
1
+ import { classifyClineFreeError } from "./errors.js";
2
+ import { PROVIDER_NAME } from "./env.js";
3
+
4
+ export function handleClineFreeError(
5
+ event: { message: unknown },
6
+ ctx: {
7
+ hasUI: boolean;
8
+ ui: { notify: (msg: string, type: "info" | "warning" | "error") => void };
9
+ model?: { provider?: string };
10
+ },
11
+ ): void {
12
+ if (!event.message) return;
13
+
14
+ const msg = event.message as {
15
+ stopReason?: string;
16
+ errorMessage?: string;
17
+ provider?: string;
18
+ };
19
+
20
+ if (msg.stopReason !== "error" || !msg.errorMessage) return;
21
+
22
+ const provider = msg.provider ?? ctx.model?.provider;
23
+ if (provider !== PROVIDER_NAME) return;
24
+
25
+ const { message: friendlyMessage } = classifyClineFreeError(msg.errorMessage);
26
+
27
+ if (ctx.hasUI) {
28
+ ctx.ui.notify(friendlyMessage, "error");
29
+ } else {
30
+ console.error(`[clinefree] ${friendlyMessage}`);
31
+ }
32
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,31 @@
1
+ export type ClineFreeErrorType = "auth_expired" | "rate_limited" | "unknown";
2
+
3
+ function matchesAny(text: string, patterns: string[]): boolean {
4
+ return patterns.some((p) => text.includes(p));
5
+ }
6
+
7
+ export const CLINEFREE_ERROR_MESSAGES: Record<ClineFreeErrorType, string> = {
8
+ auth_expired:
9
+ "Cline authentication expired. Run `pi /login` and select Cline Free, or run `cline auth` first.",
10
+ rate_limited:
11
+ "Cline rate limit reached. Wait a moment and try again.",
12
+ unknown:
13
+ "Cline request failed. Run `cline auth` or `pi /login` to refresh credentials.",
14
+ };
15
+
16
+ export function classifyClineFreeError(errorMessage: string): {
17
+ type: ClineFreeErrorType;
18
+ message: string;
19
+ } {
20
+ const lower = errorMessage.toLowerCase();
21
+
22
+ if (matchesAny(lower, ["401", "unauthorized", "invalid api key", "invalid_api_key"])) {
23
+ return { type: "auth_expired", message: CLINEFREE_ERROR_MESSAGES.auth_expired };
24
+ }
25
+
26
+ if (matchesAny(lower, ["429", "rate limit", "too many requests", "rate_limit"])) {
27
+ return { type: "rate_limited", message: CLINEFREE_ERROR_MESSAGES.rate_limited };
28
+ }
29
+
30
+ return { type: "unknown", message: CLINEFREE_ERROR_MESSAGES.unknown };
31
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Cline Free provider for pi — free z-ai gateway models via Cline CLI auth.
3
+ *
4
+ * @module pi-cline-free-provider
5
+ */
6
+
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import { resolveApiKey } from "./auth.js";
9
+ import { handleClineFreeError } from "./error-handler.js";
10
+ import { createClineFreeProvider, loadModelCatalog } from "./provider.js";
11
+
12
+ export default async function (pi: ExtensionAPI) {
13
+ const apiKey = resolveApiKey();
14
+ const models = await loadModelCatalog(apiKey);
15
+ pi.registerProvider(createClineFreeProvider(models));
16
+ pi.on("message_end", handleClineFreeError);
17
+ }
18
+
19
+ export {
20
+ MODELS,
21
+ modelIds,
22
+ fetchRemoteModels,
23
+ resolveModels,
24
+ isClineFreeGatewayModelId,
25
+ } from "./models.js";
26
+ export { PROVIDER_NAME, ENV_API_KEY, resolveApiBase } from "./env.js";
27
+ export { resolveApiKey } from "./auth.js";
28
+ export { createClineFreeProvider, loadModelCatalog } from "./provider.js";
package/src/models.ts ADDED
@@ -0,0 +1,234 @@
1
+ import { isRecord, stringValue, numberValue, booleanValue } from "./utils.js";
2
+ import { resolveApiBase } from "./env.js";
3
+
4
+ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
5
+
6
+ export type ThinkingLevelMap = Readonly<Record<ThinkingLevel, string | null>>;
7
+
8
+ export const DEFAULT_THINKING_LEVEL_MAP: ThinkingLevelMap = {
9
+ off: "none",
10
+ minimal: null,
11
+ low: "low",
12
+ medium: "medium",
13
+ high: "high",
14
+ xhigh: null,
15
+ };
16
+
17
+ export const NO_THINKING_MAP: ThinkingLevelMap = {
18
+ off: null,
19
+ minimal: null,
20
+ low: null,
21
+ medium: null,
22
+ high: null,
23
+ xhigh: null,
24
+ };
25
+
26
+ export const GLM_FLASH_THINKING_LEVEL_MAP: ThinkingLevelMap = {
27
+ off: null,
28
+ minimal: null,
29
+ low: "low",
30
+ medium: null,
31
+ high: "high",
32
+ xhigh: "max",
33
+ };
34
+
35
+ export interface ClineFreeOpenAICompat {
36
+ readonly supportsDeveloperRole: boolean;
37
+ readonly thinkingFormat?: "zai";
38
+ readonly zaiToolStream?: boolean;
39
+ }
40
+
41
+ export const CLINEFREE_OPENAI_COMPAT = {
42
+ supportsDeveloperRole: false,
43
+ thinkingFormat: "zai",
44
+ zaiToolStream: true,
45
+ } as const satisfies ClineFreeOpenAICompat;
46
+
47
+ export interface ModelConfig {
48
+ id: string;
49
+ name: string;
50
+ reasoning: boolean;
51
+ input: readonly ["text"];
52
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
53
+ contextWindow: number;
54
+ maxTokens: number;
55
+ thinkingLevelMap: ThinkingLevelMap;
56
+ compat: ClineFreeOpenAICompat;
57
+ }
58
+
59
+ interface ModelConfigBase extends Omit<ModelConfig, "compat"> {
60
+ compat?: Partial<ClineFreeOpenAICompat>;
61
+ }
62
+
63
+ const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
64
+
65
+ const MODELS_BASE: readonly ModelConfigBase[] = [
66
+ {
67
+ id: "z-ai/glm-5.3-flash",
68
+ name: "GLM 5.3 Flash (Cline free)",
69
+ reasoning: true,
70
+ input: ["text"],
71
+ cost: ZERO_COST,
72
+ contextWindow: 1_048_576,
73
+ maxTokens: 131_072,
74
+ thinkingLevelMap: GLM_FLASH_THINKING_LEVEL_MAP,
75
+ },
76
+ {
77
+ id: "z-ai/glm-5.2:free",
78
+ name: "GLM 5.2 Free (Cline)",
79
+ reasoning: true,
80
+ input: ["text"],
81
+ cost: ZERO_COST,
82
+ contextWindow: 1_048_576,
83
+ maxTokens: 131_072,
84
+ thinkingLevelMap: DEFAULT_THINKING_LEVEL_MAP,
85
+ },
86
+ {
87
+ id: "z-ai/glm-4.7-flash",
88
+ name: "GLM 4.7 Flash (Cline)",
89
+ reasoning: true,
90
+ input: ["text"],
91
+ cost: ZERO_COST,
92
+ contextWindow: 1_048_576,
93
+ maxTokens: 131_072,
94
+ thinkingLevelMap: GLM_FLASH_THINKING_LEVEL_MAP,
95
+ },
96
+ ];
97
+
98
+ export const MODELS: readonly ModelConfig[] = MODELS_BASE.map((model) => ({
99
+ ...model,
100
+ compat: {
101
+ ...CLINEFREE_OPENAI_COMPAT,
102
+ ...model.compat,
103
+ },
104
+ }));
105
+
106
+ export function modelIds(): string[] {
107
+ return MODELS.map((m) => m.id);
108
+ }
109
+
110
+ export const MODELS_ENDPOINT = "/api/v1/models";
111
+ export const MODELS_FETCH_TIMEOUT_MS = 5_000;
112
+
113
+ interface RawModelEntry {
114
+ id?: unknown;
115
+ name?: unknown;
116
+ context_length?: unknown;
117
+ max_output_tokens?: unknown;
118
+ pricing?: unknown;
119
+ reasoning?: unknown;
120
+ }
121
+
122
+ function toMicroPerToken(val: unknown, fallbackVal: number): number {
123
+ const n = numberValue(val);
124
+ return n != null ? n * 1_000_000 : fallbackVal;
125
+ }
126
+
127
+ function parseRemoteModel(raw: RawModelEntry, fallback?: ModelConfig): ModelConfig | undefined {
128
+ const id = stringValue(raw.id);
129
+ if (!id) return undefined;
130
+
131
+ const name = stringValue(raw.name) ?? fallback?.name ?? id;
132
+ const contextWindow = numberValue(raw.context_length) ?? fallback?.contextWindow ?? 128_000;
133
+ const maxTokens = numberValue(raw.max_output_tokens) ?? fallback?.maxTokens ?? 8_192;
134
+ const reasoning = booleanValue(raw.reasoning) ?? fallback?.reasoning ?? true;
135
+
136
+ const pricing = isRecord(raw.pricing) ? raw.pricing : undefined;
137
+ const cost = {
138
+ input: toMicroPerToken(pricing?.prompt, fallback?.cost.input ?? 0),
139
+ output: toMicroPerToken(pricing?.completion, fallback?.cost.output ?? 0),
140
+ cacheRead: toMicroPerToken(pricing?.cached_input, fallback?.cost.cacheRead ?? 0),
141
+ cacheWrite: fallback?.cost.cacheWrite ?? 0,
142
+ };
143
+
144
+ return {
145
+ id,
146
+ name,
147
+ reasoning,
148
+ input: ["text"],
149
+ cost,
150
+ contextWindow,
151
+ maxTokens,
152
+ thinkingLevelMap: reasoning
153
+ ? (fallback?.thinkingLevelMap ??
154
+ (id.includes("flash") ? GLM_FLASH_THINKING_LEVEL_MAP : DEFAULT_THINKING_LEVEL_MAP))
155
+ : NO_THINKING_MAP,
156
+ compat: {
157
+ ...CLINEFREE_OPENAI_COMPAT,
158
+ ...fallback?.compat,
159
+ },
160
+ };
161
+ }
162
+
163
+ export function isClineFreeGatewayModelId(id: string): boolean {
164
+ if (!id.startsWith("z-ai/")) return false;
165
+ if (id.startsWith("~")) return false;
166
+ if (id.endsWith(":batch")) return false;
167
+ return true;
168
+ }
169
+
170
+ export interface RemoteModelsOptions {
171
+ apiBase?: string;
172
+ apiKey?: string;
173
+ fetch?: typeof globalThis.fetch;
174
+ timeoutMs?: number;
175
+ }
176
+
177
+ export async function fetchRemoteModels(
178
+ options: RemoteModelsOptions = {},
179
+ ): Promise<ModelConfig[] | undefined> {
180
+ const apiBase = options.apiBase ?? resolveApiBase();
181
+ const apiKey = options.apiKey;
182
+ const fetchFn = options.fetch ?? globalThis.fetch;
183
+ const timeoutMs = options.timeoutMs ?? MODELS_FETCH_TIMEOUT_MS;
184
+
185
+ if (!apiKey || !fetchFn) return undefined;
186
+
187
+ const controller = new AbortController();
188
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
189
+
190
+ try {
191
+ const response = await fetchFn(`${apiBase}${MODELS_ENDPOINT}`, {
192
+ headers: { Authorization: `Bearer ${apiKey}` },
193
+ signal: controller.signal,
194
+ });
195
+
196
+ if (!response.ok) return undefined;
197
+
198
+ const json: unknown = await response.json();
199
+ const rawList: RawModelEntry[] = Array.isArray(json)
200
+ ? json
201
+ : isRecord(json) && Array.isArray(json.data)
202
+ ? (json.data as RawModelEntry[])
203
+ : [];
204
+
205
+ if (rawList.length === 0) return undefined;
206
+
207
+ const staticById = new Map(MODELS.map((m) => [m.id, m]));
208
+
209
+ const parsed = rawList.reduce<ModelConfig[]>((acc, raw) => {
210
+ const id = stringValue(raw?.id);
211
+ if (!id || !isClineFreeGatewayModelId(id)) return acc;
212
+ const model = parseRemoteModel(raw, staticById.get(id));
213
+ if (model) acc.push(model);
214
+ return acc;
215
+ }, []);
216
+
217
+ return parsed.length > 0 ? parsed : undefined;
218
+ } catch {
219
+ return undefined;
220
+ } finally {
221
+ clearTimeout(timer);
222
+ }
223
+ }
224
+
225
+ export async function resolveModels(
226
+ apiKey?: string,
227
+ options: RemoteModelsOptions = {},
228
+ ): Promise<readonly ModelConfig[]> {
229
+ if (apiKey) {
230
+ const remote = await fetchRemoteModels({ ...options, apiKey });
231
+ if (remote) return remote;
232
+ }
233
+ return MODELS;
234
+ }
package/src/oauth.ts ADDED
@@ -0,0 +1,84 @@
1
+ import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
2
+ import { sanitizeApiKey } from "./env.js";
3
+ import {
4
+ resolveClineAuthCredentials,
5
+ refreshWorkosToken,
6
+ credentialsFromWorkos,
7
+ WORKOS_REFRESH_MARGIN_MS,
8
+ } from "./workos.js";
9
+
10
+ const DASHBOARD_URL = "https://app.cline.bot/settings/api-keys";
11
+ const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000;
12
+
13
+ function credentialsFromApiKey(apiKey: string): OAuthCredentials {
14
+ return {
15
+ refresh: apiKey,
16
+ access: apiKey,
17
+ expires: Date.now() + TEN_YEARS_MS,
18
+ };
19
+ }
20
+
21
+ async function loginWithManualApiKey(
22
+ callbacks: OAuthLoginCallbacks,
23
+ reason?: string,
24
+ ): Promise<OAuthCredentials> {
25
+ callbacks.onAuth({ url: DASHBOARD_URL });
26
+
27
+ const apiKey = sanitizeApiKey(
28
+ await callbacks.onPrompt({
29
+ message:
30
+ (reason ? `${reason} ` : "No Cline CLI login detected. ") +
31
+ "Paste your Cline API key " +
32
+ "(create one at the dashboard that just opened, under Settings → API Keys, " +
33
+ "or run `cline auth` first to reuse your Cline CLI login):",
34
+ }),
35
+ );
36
+
37
+ if (!apiKey) throw new Error("No Cline API key provided");
38
+
39
+ return credentialsFromApiKey(apiKey);
40
+ }
41
+
42
+ async function loginWithWorkosCredentials(
43
+ clineAuth: NonNullable<ReturnType<typeof resolveClineAuthCredentials>>,
44
+ ): Promise<OAuthCredentials> {
45
+ if (clineAuth.expiresAt <= Date.now() + WORKOS_REFRESH_MARGIN_MS) {
46
+ const tempCred = credentialsFromWorkos(
47
+ clineAuth.accessToken,
48
+ clineAuth.refreshToken,
49
+ clineAuth.expiresAt,
50
+ );
51
+ return refreshWorkosToken(tempCred);
52
+ }
53
+
54
+ return credentialsFromWorkos(clineAuth.accessToken, clineAuth.refreshToken, clineAuth.expiresAt);
55
+ }
56
+
57
+ export async function login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
58
+ const clineAuth = resolveClineAuthCredentials();
59
+ if (clineAuth) {
60
+ try {
61
+ return await loginWithWorkosCredentials(clineAuth);
62
+ } catch (err) {
63
+ const message = err instanceof Error ? err.message : String(err);
64
+ console.warn(`[clinefree] WorkOS auto-login failed: ${message}`);
65
+ return loginWithManualApiKey(
66
+ callbacks,
67
+ "Cline login failed (refresh token may be expired or network is unreachable).",
68
+ );
69
+ }
70
+ }
71
+
72
+ return loginWithManualApiKey(callbacks);
73
+ }
74
+
75
+ export async function refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
76
+ if (credentials.access.startsWith("workos:")) {
77
+ return refreshWorkosToken(credentials);
78
+ }
79
+ return credentialsFromApiKey(credentials.refresh);
80
+ }
81
+
82
+ export function getApiKey(credentials: OAuthCredentials): string {
83
+ return credentials.access;
84
+ }
@@ -0,0 +1,103 @@
1
+ import {
2
+ createProvider,
3
+ openAICompletionsApi,
4
+ type Model,
5
+ type Provider,
6
+ } from "@earendil-works/pi-ai/compat";
7
+ import type {
8
+ ApiKeyCredential,
9
+ OAuthCredential,
10
+ ProviderAuthInteraction,
11
+ } from "@earendil-works/pi-ai";
12
+ import { PROVIDER_NAME, ENV_API_KEY, resolveApiBase } from "./env.js";
13
+ import { resolveModels, type ModelConfig } from "./models.js";
14
+ import { login, refreshToken, getApiKey } from "./oauth.js";
15
+ import {
16
+ interactionToOAuthCallbacks,
17
+ oauthCredentialToApiKey,
18
+ resolveAmbientAccessToken,
19
+ } from "./runtime-auth.js";
20
+
21
+ export const PROVIDER_ID = PROVIDER_NAME;
22
+
23
+ function toPiModel(model: ModelConfig, baseUrl: string): Model<"openai-completions"> {
24
+ return {
25
+ id: model.id,
26
+ name: model.name,
27
+ api: "openai-completions",
28
+ provider: PROVIDER_ID,
29
+ baseUrl,
30
+ reasoning: model.reasoning,
31
+ thinkingLevelMap: { ...model.thinkingLevelMap },
32
+ input: [...model.input],
33
+ cost: { ...model.cost },
34
+ compat: { ...model.compat },
35
+ contextWindow: model.contextWindow,
36
+ maxTokens: model.maxTokens,
37
+ };
38
+ }
39
+
40
+ export function createClineFreeProvider(
41
+ models: readonly ModelConfig[],
42
+ env: NodeJS.ProcessEnv = process.env,
43
+ ): Provider<"openai-completions"> {
44
+ const apiBase = resolveApiBase(env);
45
+ const baseUrl = `${apiBase}/api/v1`;
46
+ const piModels = models.map((model) => toPiModel(model, baseUrl));
47
+
48
+ return createProvider({
49
+ id: PROVIDER_ID,
50
+ name: "Cline Free",
51
+ baseUrl,
52
+ auth: {
53
+ apiKey: {
54
+ name: "Cline API key",
55
+ async login(interaction: ProviderAuthInteraction): Promise<ApiKeyCredential> {
56
+ const credentials = await login(interactionToOAuthCallbacks(interaction));
57
+ return { type: "api_key", key: getApiKey(credentials) };
58
+ },
59
+ async resolve({ credential, signal }) {
60
+ const stored = credential ? oauthCredentialToApiKey(credential) : undefined;
61
+ if (stored) {
62
+ return { auth: { apiKey: stored }, source: "stored Cline credential" };
63
+ }
64
+
65
+ const ambient = await resolveAmbientAccessToken(signal);
66
+ if (!ambient) return undefined;
67
+ return { auth: { apiKey: ambient.token }, source: ambient.source };
68
+ },
69
+ },
70
+ oauth: {
71
+ name: "Cline Free",
72
+ loginLabel: "Sign in with Cline CLI or API key",
73
+ async login(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
74
+ const credentials = await login(interactionToOAuthCallbacks(interaction));
75
+ return { type: "oauth", ...credentials };
76
+ },
77
+ async refresh(credential: OAuthCredential, signal: AbortSignal): Promise<OAuthCredential> {
78
+ signal.throwIfAborted();
79
+ const refreshed = await refreshToken(credential);
80
+ return { type: "oauth", ...refreshed };
81
+ },
82
+ async toAuth(credential: OAuthCredential) {
83
+ return { apiKey: getApiKey(credential) };
84
+ },
85
+ },
86
+ },
87
+ models: piModels,
88
+ async fetchModels({ signal, credential }) {
89
+ signal.throwIfAborted();
90
+ const stored = credential ? oauthCredentialToApiKey(credential) : undefined;
91
+ const apiKey = stored ?? (await resolveAmbientAccessToken(signal))?.token;
92
+ const remote = await resolveModels(apiKey, { apiBase });
93
+ return remote.map((model) => toPiModel(model, baseUrl));
94
+ },
95
+ api: openAICompletionsApi(),
96
+ });
97
+ }
98
+
99
+ export async function loadModelCatalog(apiKey?: string): Promise<readonly ModelConfig[]> {
100
+ return resolveModels(apiKey, { apiBase: resolveApiBase() });
101
+ }
102
+
103
+ export { ENV_API_KEY };
@@ -0,0 +1,96 @@
1
+ import type { ProviderAuthInteraction } from "@earendil-works/pi-ai";
2
+ import type { OAuthLoginCallbacks } from "@earendil-works/pi-ai/oauth";
3
+ import { resolveApiKey } from "./auth.js";
4
+ import { ENV_API_KEY } from "./env.js";
5
+ import {
6
+ resolveClineAuthCredentials,
7
+ refreshWorkosToken,
8
+ credentialsFromWorkos,
9
+ WORKOS_REFRESH_MARGIN_MS,
10
+ isWorkosToken,
11
+ } from "./workos.js";
12
+
13
+ export function interactionToOAuthCallbacks(
14
+ interaction: ProviderAuthInteraction,
15
+ ): OAuthLoginCallbacks {
16
+ return {
17
+ onAuth: (info) => {
18
+ interaction.notify({ type: "auth_url", url: info.url, instructions: info.instructions });
19
+ },
20
+ onDeviceCode: (info) => {
21
+ interaction.notify({
22
+ type: "device_code",
23
+ userCode: info.userCode,
24
+ verificationUri: info.verificationUri,
25
+ intervalSeconds: info.intervalSeconds,
26
+ expiresInSeconds: info.expiresInSeconds,
27
+ });
28
+ },
29
+ onPrompt: async (prompt) =>
30
+ interaction.prompt({
31
+ type: "secret",
32
+ message: prompt.message,
33
+ placeholder: prompt.placeholder,
34
+ signal: interaction.signal,
35
+ }),
36
+ onSelect: async (prompt) => {
37
+ const selected = await interaction.prompt({
38
+ type: "select",
39
+ message: prompt.message,
40
+ options: prompt.options.map((option) => ({
41
+ id: option.id,
42
+ label: option.label,
43
+ })),
44
+ signal: interaction.signal,
45
+ });
46
+ return selected;
47
+ },
48
+ onProgress: (message) => {
49
+ interaction.notify({ type: "progress", message });
50
+ },
51
+ };
52
+ }
53
+
54
+ export async function resolveAmbientAccessToken(
55
+ signal?: AbortSignal,
56
+ ): Promise<{ token: string; source: string } | undefined> {
57
+ signal?.throwIfAborted();
58
+
59
+ const envKey = process.env[ENV_API_KEY]?.trim();
60
+ if (envKey) return { token: envKey, source: "CLINE_API_KEY" };
61
+
62
+ const staticKey = resolveApiKey();
63
+ if (staticKey) return { token: staticKey, source: "Cline config" };
64
+
65
+ const clineAuth = resolveClineAuthCredentials();
66
+ if (!clineAuth) return undefined;
67
+
68
+ if (clineAuth.expiresAt <= Date.now() + WORKOS_REFRESH_MARGIN_MS) {
69
+ const refreshed = await refreshWorkosToken(
70
+ credentialsFromWorkos(
71
+ clineAuth.accessToken,
72
+ clineAuth.refreshToken,
73
+ clineAuth.expiresAt,
74
+ ),
75
+ );
76
+ return { token: refreshed.access, source: "Cline CLI OAuth" };
77
+ }
78
+
79
+ return { token: clineAuth.accessToken, source: "Cline CLI OAuth" };
80
+ }
81
+
82
+ export function oauthCredentialToApiKey(credential: {
83
+ access?: string;
84
+ refresh?: string;
85
+ type?: string;
86
+ key?: string;
87
+ }): string | undefined {
88
+ if (typeof credential.key === "string" && credential.key.trim()) {
89
+ return credential.key.trim();
90
+ }
91
+ if (typeof credential.access === "string" && credential.access.trim()) {
92
+ const access = credential.access.trim();
93
+ if (isWorkosToken(access) || access.length >= 20) return access;
94
+ }
95
+ return undefined;
96
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,21 @@
1
+ export function isRecord(value: unknown): value is Record<string, unknown> {
2
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3
+ }
4
+
5
+ export function stringValue(value: unknown): string | undefined {
6
+ return typeof value === "string" ? value : undefined;
7
+ }
8
+
9
+ export function numberValue(value: unknown): number | undefined {
10
+ if (typeof value === "number" && Number.isFinite(value)) return value;
11
+ if (typeof value === "string") {
12
+ if (value.trim() === "") return undefined;
13
+ const n = Number(value);
14
+ return Number.isFinite(n) ? n : undefined;
15
+ }
16
+ return undefined;
17
+ }
18
+
19
+ export function booleanValue(value: unknown): boolean | undefined {
20
+ return typeof value === "boolean" ? value : undefined;
21
+ }
package/src/workos.ts ADDED
@@ -0,0 +1,176 @@
1
+ import type { OAuthCredentials } from "@earendil-works/pi-ai";
2
+ import { isRecord, stringValue } from "./utils.js";
3
+ import { resolveApiBase, WORKOS_TOKEN_PREFIX } from "./env.js";
4
+ import { walkAuthPaths, type AuthKeyOptions } from "./config-store.js";
5
+
6
+ export { WORKOS_TOKEN_PREFIX };
7
+
8
+ export const CLINE_REFRESH_ENDPOINT = "/api/v1/auth/refresh";
9
+ export const WORKOS_TOKEN_LIFETIME_MS = 55 * 60 * 1000;
10
+ export const WORKOS_REFRESH_MARGIN_MS = 5 * 60 * 1000;
11
+ export const WORKOS_REFRESH_TIMEOUT_MS = 15_000;
12
+
13
+ export interface ClineAuthCredentials {
14
+ accessToken: string;
15
+ refreshToken: string;
16
+ expiresAt: number;
17
+ }
18
+
19
+ export function isWorkosToken(token: string): boolean {
20
+ return token.startsWith(WORKOS_TOKEN_PREFIX);
21
+ }
22
+
23
+ export function credentialsFromWorkos(
24
+ accessToken: string,
25
+ refreshToken: string,
26
+ expiresAt: number,
27
+ ): OAuthCredentials {
28
+ return {
29
+ access: accessToken,
30
+ refresh: refreshToken,
31
+ expires: expiresAt,
32
+ };
33
+ }
34
+
35
+ export interface WorkosRefreshOptions {
36
+ fetch?: typeof globalThis.fetch;
37
+ apiBase?: string;
38
+ }
39
+
40
+ export async function refreshWorkosToken(
41
+ credentials: OAuthCredentials,
42
+ options: WorkosRefreshOptions = {},
43
+ ): Promise<OAuthCredentials> {
44
+ const fetchFn = options.fetch ?? globalThis.fetch;
45
+ const apiBase = options.apiBase ?? resolveApiBase();
46
+
47
+ let response;
48
+ try {
49
+ response = await fetchFn(`${apiBase}${CLINE_REFRESH_ENDPOINT}`, {
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/json" },
52
+ body: JSON.stringify({
53
+ granttype: "refresh_token",
54
+ refreshToken: credentials.refresh,
55
+ }),
56
+ signal: AbortSignal.timeout(WORKOS_REFRESH_TIMEOUT_MS),
57
+ });
58
+ } catch (err) {
59
+ if (err instanceof DOMException && err.name === "AbortError") {
60
+ throw new Error(
61
+ "Cline token refresh timed out — check your network or try a static API key.",
62
+ { cause: err },
63
+ );
64
+ }
65
+ throw err;
66
+ }
67
+
68
+ if (!response.ok) {
69
+ const text = await response.text().catch(() => "unknown error");
70
+ throw new Error(
71
+ `Cline token refresh failed (${response.status}): ${text}` +
72
+ " — try running `cline auth` to re-login, or use a static API key.",
73
+ );
74
+ }
75
+
76
+ const data = (await response.json()) as {
77
+ data?: { accessToken?: string; refreshToken?: string };
78
+ accessToken?: string;
79
+ refreshToken?: string;
80
+ };
81
+
82
+ const tokens = data.data ?? data;
83
+ const newAccessToken = tokens.accessToken;
84
+ const newRefreshToken = tokens.refreshToken;
85
+
86
+ if (!newAccessToken || !newRefreshToken) {
87
+ throw new Error("Cline token refresh returned unexpected response format");
88
+ }
89
+
90
+ const prefixedToken = isWorkosToken(newAccessToken) ? newAccessToken : `workos:${newAccessToken}`;
91
+
92
+ return credentialsFromWorkos(
93
+ prefixedToken,
94
+ newRefreshToken,
95
+ Date.now() + WORKOS_TOKEN_LIFETIME_MS - WORKOS_REFRESH_MARGIN_MS,
96
+ );
97
+ }
98
+
99
+ function resolveExpiresAt(expiresField: unknown): number {
100
+ if (typeof expiresField === "number" && Number.isFinite(expiresField)) {
101
+ return expiresField;
102
+ }
103
+ return 0;
104
+ }
105
+
106
+ function extractWorkosAuthFromRecord(
107
+ auth: Record<string, unknown>,
108
+ ): ClineAuthCredentials | undefined {
109
+ const accessToken = stringValue(auth.accessToken);
110
+ const refreshToken = stringValue(auth.refreshToken);
111
+ if (!accessToken || !refreshToken || !isWorkosToken(accessToken)) return undefined;
112
+
113
+ return {
114
+ accessToken,
115
+ refreshToken,
116
+ expiresAt: resolveExpiresAt(auth.expiresAt),
117
+ };
118
+ }
119
+
120
+ function extractPiProviderAuth(parsed: Record<string, unknown>): ClineAuthCredentials | undefined {
121
+ for (const field of ["clinefree", "clinepass"] as const) {
122
+ const providerField = parsed[field];
123
+ if (!isRecord(providerField)) continue;
124
+
125
+ const accessToken = stringValue(providerField.access);
126
+ const refreshToken = stringValue(providerField.refresh);
127
+ if (!accessToken || !refreshToken || !isWorkosToken(accessToken)) continue;
128
+
129
+ return {
130
+ accessToken,
131
+ refreshToken,
132
+ expiresAt: resolveExpiresAt(providerField.expires),
133
+ };
134
+ }
135
+ return undefined;
136
+ }
137
+
138
+ function collectClineProviderAuths(parsed: Record<string, unknown>): ClineAuthCredentials[] {
139
+ const providers = isRecord(parsed.providers) ? parsed.providers : undefined;
140
+ if (!providers) return [];
141
+
142
+ const results: ClineAuthCredentials[] = [];
143
+ for (const key of ["cline-pass", "cline"]) {
144
+ const provider = isRecord(providers[key]) ? providers[key] : undefined;
145
+ if (!provider) continue;
146
+ const settings = isRecord(provider.settings) ? provider.settings : undefined;
147
+ if (!settings) continue;
148
+ const auth = isRecord(settings.auth) ? settings.auth : undefined;
149
+ if (!auth) continue;
150
+ const cred = extractWorkosAuthFromRecord(auth);
151
+ if (cred) results.push(cred);
152
+ }
153
+ return results;
154
+ }
155
+
156
+ function pickFreshestAuth(candidates: ClineAuthCredentials[]): ClineAuthCredentials | undefined {
157
+ if (candidates.length === 0) return undefined;
158
+ return candidates.reduce((best, current) =>
159
+ current.expiresAt > best.expiresAt ? current : best,
160
+ );
161
+ }
162
+
163
+ export function resolveClineAuthCredentials(
164
+ options: AuthKeyOptions = {},
165
+ ): ClineAuthCredentials | undefined {
166
+ const candidates: ClineAuthCredentials[] = [];
167
+
168
+ walkAuthPaths(options, (parsed) => {
169
+ const piAuth = extractPiProviderAuth(parsed);
170
+ if (piAuth) candidates.push(piAuth);
171
+ candidates.push(...collectClineProviderAuths(parsed));
172
+ return undefined;
173
+ });
174
+
175
+ return pickFreshestAuth(candidates);
176
+ }
@@ -0,0 +1,62 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { resolveApiKey } from "../src/auth.ts";
4
+
5
+ describe("resolveApiKey", () => {
6
+ it("prefers provided key", () => {
7
+ assert.equal(resolveApiKey("provided"), "provided");
8
+ });
9
+
10
+ it("reads CLINE_API_KEY from env", () => {
11
+ assert.equal(
12
+ resolveApiKey(undefined, { env: { CLINE_API_KEY: "env-key" } }),
13
+ "env-key",
14
+ );
15
+ });
16
+
17
+ it("reads clinepass OAuth from pi auth.json", () => {
18
+ const key = resolveApiKey(undefined, {
19
+ authPaths: ["/virtual/auth.json"],
20
+ fileExists: (p) => p === "/virtual/auth.json",
21
+ readFile: () =>
22
+ JSON.stringify({
23
+ clinepass: {
24
+ type: "oauth",
25
+ access: "workos:token",
26
+ refresh: "refresh",
27
+ },
28
+ }),
29
+ });
30
+ assert.equal(key, undefined);
31
+ });
32
+
33
+ it("reads static API key from clinefree auth field", () => {
34
+ const key = resolveApiKey(undefined, {
35
+ authPaths: ["/virtual/auth.json"],
36
+ fileExists: (p) => p === "/virtual/auth.json",
37
+ readFile: () =>
38
+ JSON.stringify({
39
+ clinefree: "static-api-key-from-pi-login",
40
+ }),
41
+ });
42
+ assert.equal(key, "static-api-key-from-pi-login");
43
+ });
44
+
45
+ it("reads static API key from Cline CLI providers.json", () => {
46
+ const key = resolveApiKey(undefined, {
47
+ authPaths: ["/virtual/providers.json"],
48
+ fileExists: (p) => p === "/virtual/providers.json",
49
+ readFile: () =>
50
+ JSON.stringify({
51
+ providers: {
52
+ "cline-pass": {
53
+ settings: {
54
+ apiKey: "cli-static-key",
55
+ },
56
+ },
57
+ },
58
+ }),
59
+ });
60
+ assert.equal(key, "cli-static-key");
61
+ });
62
+ });
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
5
+ cd "$ROOT"
6
+
7
+ if ! command -v pi >/dev/null 2>&1; then
8
+ echo "skip: pi not installed"
9
+ exit 0
10
+ fi
11
+
12
+ pi --list-models clinefree 2>&1 | grep -q "z-ai/glm-5.3-flash"
13
+ echo "smoke ok: clinefree provider lists glm-5.3-flash"
@@ -0,0 +1,89 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ MODELS,
5
+ modelIds,
6
+ isClineFreeGatewayModelId,
7
+ fetchRemoteModels,
8
+ resolveModels,
9
+ GLM_FLASH_THINKING_LEVEL_MAP,
10
+ } from "../src/models.ts";
11
+
12
+ describe("modelIds", () => {
13
+ it("returns static fallback model IDs", () => {
14
+ const ids = modelIds();
15
+ assert.equal(ids.length, MODELS.length);
16
+ assert.ok(ids.includes("z-ai/glm-5.3-flash"));
17
+ assert.ok(ids.includes("z-ai/glm-5.2:free"));
18
+ assert.ok(ids.includes("z-ai/glm-4.7-flash"));
19
+ });
20
+
21
+ it("all static IDs are z-ai gateway models", () => {
22
+ for (const id of modelIds()) {
23
+ assert.ok(isClineFreeGatewayModelId(id));
24
+ }
25
+ });
26
+ });
27
+
28
+ describe("isClineFreeGatewayModelId", () => {
29
+ it("accepts z-ai models", () => {
30
+ assert.equal(isClineFreeGatewayModelId("z-ai/glm-5.3-flash"), true);
31
+ assert.equal(isClineFreeGatewayModelId("z-ai/glm-5.2:free"), true);
32
+ });
33
+
34
+ it("rejects batch and alias models", () => {
35
+ assert.equal(isClineFreeGatewayModelId("z-ai/glm-5.3-flash:batch"), false);
36
+ assert.equal(isClineFreeGatewayModelId("~z-ai/glm-latest"), false);
37
+ assert.equal(isClineFreeGatewayModelId("cline-pass/glm-5.3"), false);
38
+ });
39
+ });
40
+
41
+ describe("fetchRemoteModels", () => {
42
+ it("filters remote list to z-ai gateway models", async () => {
43
+ const fetch = async () =>
44
+ ({
45
+ ok: true,
46
+ json: async () => ({
47
+ data: [
48
+ { id: "z-ai/glm-5.3-flash", name: "GLM 5.3 Flash" },
49
+ { id: "z-ai/glm-5.3-flash:batch", name: "batch" },
50
+ { id: "~z-ai/glm-latest", name: "alias" },
51
+ { id: "cline-pass/glm-5.3", name: "paid" },
52
+ ],
53
+ }),
54
+ }) as Response;
55
+
56
+ const models = await fetchRemoteModels({ apiKey: "test-key", fetch });
57
+ assert.ok(models);
58
+ assert.equal(models.length, 1);
59
+ assert.equal(models[0]?.id, "z-ai/glm-5.3-flash");
60
+ });
61
+
62
+ it("returns undefined when fetch fails", async () => {
63
+ const fetch = async () => ({ ok: false }) as Response;
64
+ const models = await fetchRemoteModels({ apiKey: "test-key", fetch });
65
+ assert.equal(models, undefined);
66
+ });
67
+ });
68
+
69
+ describe("resolveModels", () => {
70
+ it("falls back to static catalog without an API key", async () => {
71
+ const models = await resolveModels(undefined);
72
+ assert.equal(models.length, MODELS.length);
73
+ assert.equal(models[0]?.id, "z-ai/glm-5.3-flash");
74
+ });
75
+
76
+ it("uses remote models when available", async () => {
77
+ const fetch = async () =>
78
+ ({
79
+ ok: true,
80
+ json: async () => ({
81
+ data: [{ id: "z-ai/glm-5.3-flash", name: "GLM 5.3 Flash" }],
82
+ }),
83
+ }) as Response;
84
+
85
+ const models = await resolveModels("test-key", { fetch });
86
+ assert.equal(models.length, 1);
87
+ assert.equal(models[0]?.thinkingLevelMap.low, GLM_FLASH_THINKING_LEVEL_MAP.low);
88
+ });
89
+ });
@@ -0,0 +1,23 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { isClineFreeGatewayModelId } from "../src/models.ts";
4
+ import { oauthCredentialToApiKey } from "../src/runtime-auth.ts";
5
+
6
+ describe("oauthCredentialToApiKey", () => {
7
+ it("reads api_key credentials", () => {
8
+ assert.equal(oauthCredentialToApiKey({ type: "api_key", key: "abc" }), "abc");
9
+ });
10
+
11
+ it("reads oauth access tokens", () => {
12
+ assert.equal(
13
+ oauthCredentialToApiKey({ type: "oauth", access: "workos:token", refresh: "r" }),
14
+ "workos:token",
15
+ );
16
+ });
17
+ });
18
+
19
+ describe("gateway model filter", () => {
20
+ it("includes glm-5.3-flash", () => {
21
+ assert.equal(isClineFreeGatewayModelId("z-ai/glm-5.3-flash"), true);
22
+ });
23
+ });