jeo-code 0.9.16 → 0.10.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.
@@ -0,0 +1,321 @@
1
+ /**
2
+ * User-defined ("custom") provider registry — gjc/gajae-code parity.
3
+ *
4
+ * jeo ships a fixed, data-driven catalog of OpenAI/Anthropic-compatible clouds
5
+ * (`OPENAI_COMPAT_PROVIDERS`). That table is compiled in: a user could not point jeo
6
+ * at their own gateway (LiteLLM behind a company proxy, a self-hosted vLLM, an
7
+ * OpenRouter-style aggregator, a corporate Anthropic proxy) without patching source.
8
+ * The single escape hatch was `config.openaiBaseUrl`, which rebinds the ONE built-in
9
+ * `openai` provider — so only one custom endpoint could exist at a time, it stole the
10
+ * `openai/` routing prefix, and its models collided with real OpenAI ids.
11
+ *
12
+ * This module adds NAMED custom providers: each gets its own routing prefix, base URL,
13
+ * wire protocol, credential source, and model list, persisted under
14
+ * `config.customProviders`. They are surfaced through the SAME lookup the built-in
15
+ * catalog uses (`openaiCompatDef`), so discovery, routing, `/model`, `/provider`,
16
+ * status, and the adapter factories all pick them up with no per-call-site branching.
17
+ *
18
+ * Design constraints (kept deliberately narrow so the generic paths stay simple):
19
+ * - `id` is the routing prefix (`<id>/…`) AND the config/auth key — same rule as the
20
+ * built-in catalog, so `resolveProvider()` needs no special case.
21
+ * - Only the two wire protocols jeo already speaks are allowed ("openai" | "anthropic").
22
+ * - The credential is either a literal key stored in config, or the name of an env var.
23
+ * Env is the default and the recommended path (nothing secret lands on disk).
24
+ */
25
+ import type { ProviderName } from "../types";
26
+ import type { OpenAICompatProviderDef } from "./openai-compatible-catalog";
27
+
28
+ /** Wire protocols a custom provider may speak — the two jeo already implements. */
29
+ export type ProviderCompatibility = "openai" | "anthropic";
30
+
31
+ /** Where a custom provider's API key comes from. */
32
+ export type CredentialSource = "env" | "literal" | "none";
33
+
34
+ /** One user-defined provider, as persisted under `config.customProviders[id]`. */
35
+ export interface CustomProviderConfig {
36
+ /** Display label (defaults to the id when absent). */
37
+ label?: string;
38
+ /** API base URL. OpenAI protocol → `${base}/chat/completions` + `${base}/models`;
39
+ * Anthropic protocol → `${base}/v1/messages` + `${base}/v1/models`. */
40
+ baseUrl: string;
41
+ /** Wire protocol. Defaults to "openai". */
42
+ protocol?: ProviderCompatibility;
43
+ /** Env var holding the API key. Defaults to `<ID>_API_KEY` (id upper-cased, `-`/`.` → `_`). */
44
+ apiKeyEnv?: string;
45
+ /** Literal API key. Only set when the user explicitly chose the "literal" source;
46
+ * redacted everywhere it is displayed. `apiKeyEnv` wins when both resolve. */
47
+ apiKey?: string;
48
+ /** Known model ids (BARE, not prefixed) for the offline pick-list. Live `/models`
49
+ * discovery supersedes this once the endpoint answers. */
50
+ models?: string[];
51
+ /** Default model id (BARE or prefixed) used by `--provider <id>`. */
52
+ defaultModel?: string;
53
+ /** Native-reasoning enablement, same semantics as the built-in catalog. */
54
+ thinkingFormat?: "openai" | "openrouter" | "qwen" | "zai";
55
+ /** The preset this entry was created from, when applicable (informational). */
56
+ preset?: string;
57
+ }
58
+
59
+ export type CustomProviderMap = Record<string, CustomProviderConfig>;
60
+
61
+ /** A validated custom provider, normalized into the catalog's own shape. */
62
+ export interface CustomProviderDef extends OpenAICompatProviderDef {
63
+ /** True — distinguishes a user entry from a compiled-in catalog row. */
64
+ readonly custom: true;
65
+ /** Literal key when the user chose the "literal" credential source. */
66
+ readonly literalApiKey?: string;
67
+ /** Preset id this entry came from, when applicable. */
68
+ readonly preset?: string;
69
+ }
70
+
71
+ /** Provider ids the user may NOT claim: every compiled-in provider plus reserved words. */
72
+ export const RESERVED_PROVIDER_IDS: readonly string[] = [
73
+ "anthropic",
74
+ "openai",
75
+ "gemini",
76
+ "antigravity",
77
+ "ollama",
78
+ "lmstudio",
79
+ "xai",
80
+ "kimi",
81
+ // Reserved so a future built-in cannot be shadowed by a stale user entry.
82
+ "google",
83
+ "vertex",
84
+ "bedrock",
85
+ "azure",
86
+ "copilot",
87
+ "cursor",
88
+ "codex",
89
+ "auto",
90
+ "default",
91
+ "none",
92
+ "all",
93
+ "help",
94
+ "clear",
95
+ "list",
96
+ "add",
97
+ "remove",
98
+ "rm",
99
+ ];
100
+
101
+ /** Same grammar gajae-code enforces: lowercase, starts alphanumeric, `.`/`_`/`-` inside. */
102
+ export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
103
+
104
+ const MAX_PROVIDER_ID_LENGTH = 48;
105
+ const REDACT_PREFIX = 4;
106
+ const REDACT_SUFFIX = 4;
107
+
108
+ /** Lowercase + trim. Ids are case-insensitive so `/model MyProxy/x` still routes. */
109
+ export function normalizeProviderId(id: string): string {
110
+ return id.trim().toLowerCase();
111
+ }
112
+
113
+ /** Derived env var for a provider id: `my-proxy` → `MY_PROXY_API_KEY`. */
114
+ export function defaultApiKeyEnv(id: string): string {
115
+ return `${normalizeProviderId(id).replace(/[.\-]/g, "_").toUpperCase()}_API_KEY`;
116
+ }
117
+
118
+ /**
119
+ * Validate a candidate custom-provider id. Throws with an actionable message rather
120
+ * than returning a boolean, because every call site (CLI, slash command, config load)
121
+ * wants the reason to show the user.
122
+ */
123
+ export function assertValidProviderId(rawId: string, reserved: readonly string[] = RESERVED_PROVIDER_IDS): string {
124
+ const id = normalizeProviderId(rawId);
125
+ if (!id) throw new Error("Provider id is required (e.g. --id my-proxy).");
126
+ if (id.length > MAX_PROVIDER_ID_LENGTH) {
127
+ throw new Error(`Provider id '${id}' is too long (max ${MAX_PROVIDER_ID_LENGTH} characters).`);
128
+ }
129
+ if (!PROVIDER_ID_PATTERN.test(id)) {
130
+ throw new Error(
131
+ `Invalid provider id '${id}'. Use lowercase letters, digits, '.', '_' or '-', starting with a letter or digit.`,
132
+ );
133
+ }
134
+ if ((reserved as readonly string[]).includes(id)) {
135
+ throw new Error(`Provider id '${id}' is reserved by a built-in provider — pick another id.`);
136
+ }
137
+ return id;
138
+ }
139
+
140
+ /** Parse a compatibility flag the way gajae-code does (accepts the common aliases). */
141
+ export function parseProviderCompatibility(value: string): ProviderCompatibility {
142
+ const v = value.trim().toLowerCase();
143
+ if (v === "openai" || v === "openai-compatible" || v === "oai" || v === "chat" || v === "completions") return "openai";
144
+ if (v === "anthropic" || v === "anthropic-compatible" || v === "claude" || v === "messages") return "anthropic";
145
+ throw new Error(`Provider compatibility must be 'openai' or 'anthropic' (got '${value}').`);
146
+ }
147
+
148
+ /**
149
+ * Normalize + validate a base URL. Rejects anything that is not an absolute http(s)
150
+ * URL so a typo ("localhost:1234") fails at registration time with a clear message
151
+ * instead of at the first inference call with an opaque fetch error.
152
+ */
153
+ export function normalizeCustomBaseUrl(raw: string): string {
154
+ const trimmed = raw.trim();
155
+ if (!trimmed) throw new Error("Base URL is required (e.g. --base-url https://api.example.com/v1).");
156
+ // `new URL("localhost:1234/v1")` PARSES — as scheme "localhost:" — so a missing scheme
157
+ // would otherwise surface as a confusing "must use http or https (got 'localhost')".
158
+ // Check for the authority marker first and give the actionable hint instead.
159
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
160
+ throw new Error(`Invalid base URL '${raw}' — include the scheme, e.g. https://api.example.com/v1`);
161
+ }
162
+ let parsed: URL;
163
+ try {
164
+ parsed = new URL(trimmed);
165
+ } catch {
166
+ throw new Error(`Invalid base URL '${raw}' — include the scheme, e.g. https://api.example.com/v1`);
167
+ }
168
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
169
+ throw new Error(`Base URL must use http or https (got '${parsed.protocol.replace(":", "")}').`);
170
+ }
171
+ if (!parsed.hostname) throw new Error(`Invalid base URL '${raw}' — no host.`);
172
+ // Keep the path (gateways live under /v1, /api/v1, /openai/v1 …); drop a trailing
173
+ // slash so `${base}/chat/completions` never produces a double slash.
174
+ const path = parsed.pathname.replace(/\/+$/, "");
175
+ return `${parsed.origin}${path}${parsed.search}`;
176
+ }
177
+
178
+ /** `sk-abcd…wxyz` — never print a raw key back to the user or a log. */
179
+ export function redactSecret(secret: string): string {
180
+ const trimmed = secret.trim();
181
+ if (!trimmed) return "";
182
+ if (trimmed.length <= REDACT_PREFIX + REDACT_SUFFIX) return "***";
183
+ return `${trimmed.slice(0, REDACT_PREFIX)}…${trimmed.slice(-REDACT_SUFFIX)}`;
184
+ }
185
+
186
+ /** Split/flatten `--model a,b --model c` into a deduped, ordered id list. */
187
+ export function parseModelList(values: readonly string[]): string[] {
188
+ const out = values
189
+ .flatMap(v => v.split(","))
190
+ .map(v => v.trim())
191
+ .filter(v => v.length > 0);
192
+ return [...new Set(out)];
193
+ }
194
+
195
+ /** Strip a `<id>/` routing prefix from a model id, if present. */
196
+ export function stripProviderPrefix(id: string, modelId: string): string {
197
+ const prefix = `${id}/`;
198
+ return modelId.startsWith(prefix) ? modelId.slice(prefix.length) : modelId;
199
+ }
200
+
201
+ /**
202
+ * Normalize one persisted entry into the catalog shape every generic path consumes.
203
+ * Throws when the entry is unusable (bad id / base URL) so a corrupt config surfaces
204
+ * as one clear error rather than a provider that 404s on every call.
205
+ */
206
+ export function toCustomProviderDef(rawId: string, cfg: CustomProviderConfig): CustomProviderDef {
207
+ const id = assertValidProviderId(rawId);
208
+ const baseUrl = normalizeCustomBaseUrl(cfg.baseUrl);
209
+ const protocol: ProviderCompatibility = cfg.protocol === "anthropic" ? "anthropic" : "openai";
210
+ const apiKeyEnv = cfg.apiKeyEnv?.trim() || defaultApiKeyEnv(id);
211
+ const models = (cfg.models ?? []).map(m => stripProviderPrefix(id, m.trim())).filter(Boolean);
212
+ const bareDefault = cfg.defaultModel ? stripProviderPrefix(id, cfg.defaultModel.trim()) : models[0];
213
+ return {
214
+ custom: true,
215
+ name: id as ProviderName,
216
+ label: cfg.label?.trim() || id,
217
+ baseUrl,
218
+ apiKeyEnv,
219
+ // `defaultModel` is provider-qualified in the catalog contract; fall back to a
220
+ // placeholder that makes the missing-model state obvious instead of routing to "".
221
+ defaultModel: bareDefault ? `${id}/${bareDefault}` : `${id}/`,
222
+ knownModels: models.length ? models : undefined,
223
+ protocol,
224
+ thinkingFormat: cfg.thinkingFormat,
225
+ literalApiKey: cfg.apiKey?.trim() || undefined,
226
+ preset: cfg.preset,
227
+ };
228
+ }
229
+
230
+ /** Which credential source a def will actually use at call time. */
231
+ export function credentialSourceOf(def: CustomProviderDef, env: NodeJS.ProcessEnv = process.env): CredentialSource {
232
+ if (env[def.apiKeyEnv]) return "env";
233
+ if (def.literalApiKey) return "literal";
234
+ return "none";
235
+ }
236
+
237
+ /**
238
+ * Resolve the API key for a custom provider. Env wins over the stored literal so a
239
+ * rotated key in the shell takes effect without editing config.json.
240
+ */
241
+ export function resolveCustomApiKey(def: CustomProviderDef, env: NodeJS.ProcessEnv = process.env): string | undefined {
242
+ return env[def.apiKeyEnv] || def.literalApiKey || undefined;
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Runtime registry
247
+ // ---------------------------------------------------------------------------
248
+
249
+ let CUSTOM_DEFS: readonly CustomProviderDef[] = [];
250
+ let CUSTOM_BY_ID = new Map<string, CustomProviderDef>();
251
+
252
+ /** Listeners fired whenever the custom set changes (registry rebuild, status refresh). */
253
+ const listeners = new Set<(defs: readonly CustomProviderDef[]) => void>();
254
+
255
+ export function onCustomProvidersChanged(fn: (defs: readonly CustomProviderDef[]) => void): () => void {
256
+ listeners.add(fn);
257
+ return () => listeners.delete(fn);
258
+ }
259
+
260
+ /**
261
+ * Replace the active custom provider set. Invalid entries are SKIPPED (with their
262
+ * reason returned) rather than throwing: one bad hand-edited row must never stop jeo
263
+ * from starting with the rest of the user's providers intact.
264
+ */
265
+ export function setCustomProviders(map: CustomProviderMap | undefined): { defs: readonly CustomProviderDef[]; errors: string[] } {
266
+ const defs: CustomProviderDef[] = [];
267
+ const errors: string[] = [];
268
+ const seen = new Set<string>();
269
+ for (const [rawId, cfg] of Object.entries(map ?? {})) {
270
+ if (!cfg || typeof cfg !== "object") {
271
+ errors.push(`customProviders.${rawId}: not an object — skipped.`);
272
+ continue;
273
+ }
274
+ try {
275
+ const def = toCustomProviderDef(rawId, cfg);
276
+ if (seen.has(def.name)) {
277
+ errors.push(`customProviders.${rawId}: duplicate id '${def.name}' — skipped.`);
278
+ continue;
279
+ }
280
+ seen.add(def.name);
281
+ defs.push(def);
282
+ } catch (err) {
283
+ errors.push(`customProviders.${rawId}: ${(err as Error).message}`);
284
+ }
285
+ }
286
+ CUSTOM_DEFS = defs;
287
+ CUSTOM_BY_ID = new Map(defs.map(d => [d.name, d]));
288
+ for (const fn of listeners) {
289
+ try {
290
+ fn(CUSTOM_DEFS);
291
+ } catch {
292
+ // A misbehaving listener must not break provider registration.
293
+ }
294
+ }
295
+ return { defs: CUSTOM_DEFS, errors };
296
+ }
297
+
298
+ /** Every registered custom provider, in config order. */
299
+ export function customProviderDefs(): readonly CustomProviderDef[] {
300
+ return CUSTOM_DEFS;
301
+ }
302
+
303
+ /** Lookup by id (already normalized or not). */
304
+ export function customProviderDef(name: string): CustomProviderDef | undefined {
305
+ return CUSTOM_BY_ID.get(normalizeProviderId(name));
306
+ }
307
+
308
+ /** True when `name` is a user-registered custom provider. */
309
+ export function isCustomProvider(name: string): boolean {
310
+ return CUSTOM_BY_ID.has(normalizeProviderId(name));
311
+ }
312
+
313
+ /** All custom provider ids. */
314
+ export function customProviderNames(): readonly ProviderName[] {
315
+ return CUSTOM_DEFS.map(d => d.name);
316
+ }
317
+
318
+ /** Drop every registered custom provider (test isolation / `/provider add clear`). */
319
+ export function clearCustomProviders(): void {
320
+ setCustomProviders({});
321
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ProviderName } from "../types";
2
+ import { customProviderDef, customProviderDefs, customProviderNames } from "./custom-providers";
2
3
 
3
4
  /**
4
5
  * gjc-style data-driven provider catalog. Every entry here is an OpenAI-compatible
@@ -81,19 +82,44 @@ export const OPENAI_COMPAT_PROVIDERS: readonly OpenAICompatProviderDef[] = [
81
82
 
82
83
  const BY_NAME = new Map<string, OpenAICompatProviderDef>(OPENAI_COMPAT_PROVIDERS.map(p => [p.name, p]));
83
84
 
84
- /** All catalog provider names (for PROVIDER_NAMES / AuthProvider unions). */
85
+ /** All BUILT-IN catalog provider names (for PROVIDER_NAMES / AuthProvider unions). */
85
86
  export const OPENAI_COMPAT_NAMES: readonly ProviderName[] = OPENAI_COMPAT_PROVIDERS.map(p => p.name);
86
87
 
88
+ /** True when `name` is one of the compiled-in catalog rows (excludes custom providers). */
89
+ export function isBuiltinCompatProvider(name: string): boolean {
90
+ return BY_NAME.has(name);
91
+ }
92
+
93
+ /** Built-in catalog rows PLUS the user's registered custom providers, in that order.
94
+ * Every generic path (discovery, routing, status, pickers) reads this so a custom
95
+ * provider behaves exactly like a compiled-in one. */
96
+ export function allCompatProviders(): readonly OpenAICompatProviderDef[] {
97
+ const custom = customProviderDefs();
98
+ return custom.length ? [...OPENAI_COMPAT_PROVIDERS, ...custom] : OPENAI_COMPAT_PROVIDERS;
99
+ }
100
+
101
+ /** Built-in + custom compat provider names. */
102
+ export function allCompatNames(): readonly ProviderName[] {
103
+ const custom = customProviderNames();
104
+ return custom.length ? [...OPENAI_COMPAT_NAMES, ...custom] : OPENAI_COMPAT_NAMES;
105
+ }
106
+
87
107
  /** Subscription/plan-tier provider names (coding-plan, portal, token-plan, code) — surfaced
88
108
  * under the `/provider` "OAuth / subscription" onboarding path rather than the generic API-key list. */
89
109
  export const SUBSCRIPTION_PROVIDER_NAMES: readonly ProviderName[] = OPENAI_COMPAT_PROVIDERS.filter(p => p.subscription).map(p => p.name);
90
110
 
91
- /** Catalog entry for a provider name, or undefined when it is not catalog-driven. */
111
+ /**
112
+ * Catalog entry for a provider name, or undefined when it is not catalog-driven.
113
+ *
114
+ * Built-ins win over custom entries with the same id — `assertValidProviderId` already
115
+ * rejects reserved ids at registration, so this is defence-in-depth for a hand-edited
116
+ * config that predates the check: a stale user row can never shadow a shipped provider.
117
+ */
92
118
  export function openaiCompatDef(name: string): OpenAICompatProviderDef | undefined {
93
- return BY_NAME.get(name);
119
+ return BY_NAME.get(name) ?? customProviderDef(name);
94
120
  }
95
121
 
96
- /** True when `name` is a catalog-driven OpenAI-compatible provider. */
122
+ /** True when `name` is a catalog-driven OpenAI-compatible provider (built-in or custom). */
97
123
  export function isOpenAICompatProvider(name: string): boolean {
98
- return BY_NAME.has(name);
124
+ return BY_NAME.has(name) || customProviderDef(name) !== undefined;
99
125
  }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Provider presets — one-flag onboarding for well-known OpenAI/Anthropic-compatible
3
+ * gateways (gjc/gajae-code parity).
4
+ *
5
+ * A preset is a pre-filled {@link CustomProviderConfig}: base URL, wire protocol,
6
+ * credential env var, and (when the endpoint has a fixed catalog) the model ids. It
7
+ * exists so the common case is `jeo provider add --preset litellm --base-url <url>`
8
+ * instead of remembering four flags and an exact URL.
9
+ *
10
+ * Two kinds:
11
+ * - FIXED presets pin a base URL (the vendor runs the endpoint).
12
+ * - PARAMETERIZED presets (`parameterized: true`) are proxies/gateways the USER runs,
13
+ * so `--base-url` is required and the preset only supplies protocol + env var +
14
+ * discovery expectations.
15
+ *
16
+ * Presets never contain secrets. `apiKeyEnv` names the environment variable to read.
17
+ */
18
+ import type { CustomProviderConfig, ProviderCompatibility } from "./custom-providers";
19
+
20
+ export interface ProviderPreset {
21
+ /** Canonical preset id (what the user types after `--preset`). */
22
+ readonly id: string;
23
+ /** Extra accepted spellings. */
24
+ readonly aliases: readonly string[];
25
+ /** Display name. */
26
+ readonly label: string;
27
+ /** One-line description shown by `/provider presets`. */
28
+ readonly description: string;
29
+ /** Wire protocol. */
30
+ readonly protocol: ProviderCompatibility;
31
+ /** Default provider id created when the user does not pass `--id`. */
32
+ readonly providerId: string;
33
+ /** Fixed base URL. Absent for parameterized presets. */
34
+ readonly baseUrl?: string;
35
+ /** Env var that supplies the API key. */
36
+ readonly apiKeyEnv: string;
37
+ /** Known model ids (BARE). Absent when the endpoint supports live `/models`. */
38
+ readonly models?: readonly string[];
39
+ /** Preferred default model (BARE). */
40
+ readonly defaultModel?: string;
41
+ /** Native-reasoning enablement for this backend. */
42
+ readonly thinkingFormat?: CustomProviderConfig["thinkingFormat"];
43
+ /** True when the user must supply `--base-url` (self-hosted / account-scoped gateway). */
44
+ readonly parameterized?: boolean;
45
+ }
46
+
47
+ export const PROVIDER_PRESETS: readonly ProviderPreset[] = [
48
+ // --- Self-hosted / user-run gateways (base URL required) --------------------
49
+ {
50
+ id: "openai-compatible-proxy",
51
+ aliases: ["openai-proxy", "compatible-proxy", "custom-proxy", "proxy"],
52
+ label: "OpenAI-Compatible Proxy",
53
+ description: "Generic OpenAI-compatible gateway with live /models discovery (requires --base-url)",
54
+ protocol: "openai",
55
+ providerId: "openai-compatible-proxy",
56
+ apiKeyEnv: "OPENAI_COMPATIBLE_API_KEY",
57
+ parameterized: true,
58
+ },
59
+ {
60
+ id: "anthropic-compatible-proxy",
61
+ aliases: ["anthropic-proxy", "claude-proxy", "messages-proxy"],
62
+ label: "Anthropic-Compatible Proxy",
63
+ description: "Generic Anthropic Messages gateway, /v1/messages wire format (requires --base-url)",
64
+ protocol: "anthropic",
65
+ providerId: "anthropic-compatible-proxy",
66
+ apiKeyEnv: "ANTHROPIC_COMPATIBLE_API_KEY",
67
+ parameterized: true,
68
+ },
69
+ {
70
+ id: "litellm",
71
+ aliases: ["litellm-proxy"],
72
+ label: "LiteLLM Proxy",
73
+ description: "Self-hosted LiteLLM proxy; its catalog is whatever your config.yaml routes (requires --base-url)",
74
+ protocol: "openai",
75
+ providerId: "litellm-proxy",
76
+ apiKeyEnv: "LITELLM_API_KEY",
77
+ parameterized: true,
78
+ },
79
+ {
80
+ id: "vllm",
81
+ aliases: ["vllm-server"],
82
+ label: "vLLM",
83
+ description: "Self-hosted vLLM OpenAI-compatible server (requires --base-url, usually http://host:8000/v1)",
84
+ protocol: "openai",
85
+ providerId: "vllm",
86
+ apiKeyEnv: "VLLM_API_KEY",
87
+ parameterized: true,
88
+ },
89
+ {
90
+ id: "sglang",
91
+ aliases: ["sgl"],
92
+ label: "SGLang",
93
+ description: "Self-hosted SGLang OpenAI-compatible server (requires --base-url, usually http://host:30000/v1)",
94
+ protocol: "openai",
95
+ providerId: "sglang",
96
+ apiKeyEnv: "SGLANG_API_KEY",
97
+ parameterized: true,
98
+ },
99
+ {
100
+ id: "llama-cpp",
101
+ aliases: ["llamacpp", "llama.cpp", "llama-server"],
102
+ label: "llama.cpp server",
103
+ description: "Self-hosted llama.cpp server (requires --base-url, usually http://localhost:8080/v1)",
104
+ protocol: "openai",
105
+ providerId: "llama-cpp",
106
+ apiKeyEnv: "LLAMA_CPP_API_KEY",
107
+ parameterized: true,
108
+ },
109
+ {
110
+ id: "azure-openai",
111
+ aliases: ["azure", "aoai"],
112
+ label: "Azure OpenAI",
113
+ description: "Azure OpenAI deployment served over the OpenAI wire format (requires --base-url with api-version)",
114
+ protocol: "openai",
115
+ providerId: "azure-openai",
116
+ apiKeyEnv: "AZURE_OPENAI_API_KEY",
117
+ parameterized: true,
118
+ },
119
+ {
120
+ id: "vercel-ai-gateway",
121
+ aliases: ["vercel", "ai-gateway"],
122
+ label: "Vercel AI Gateway",
123
+ description: "Vercel AI Gateway OpenAI-compatible endpoint (requires --base-url for your gateway)",
124
+ protocol: "openai",
125
+ providerId: "vercel-ai-gateway",
126
+ apiKeyEnv: "AI_GATEWAY_API_KEY",
127
+ parameterized: true,
128
+ },
129
+ {
130
+ id: "cloudflare-ai-gateway",
131
+ aliases: ["cloudflare", "cf-gateway"],
132
+ label: "Cloudflare AI Gateway",
133
+ description: "Cloudflare AI Gateway OpenAI-compatible endpoint (requires --base-url for your account/gateway)",
134
+ protocol: "openai",
135
+ providerId: "cloudflare-ai-gateway",
136
+ apiKeyEnv: "CLOUDFLARE_AI_GATEWAY_API_KEY",
137
+ parameterized: true,
138
+ },
139
+
140
+ // --- Vendor-hosted endpoints (fixed base URL) -------------------------------
141
+ {
142
+ id: "glm",
143
+ aliases: ["zai-openai", "z-ai-openai", "bigmodel"],
144
+ label: "GLM / z.ai (OpenAI wire)",
145
+ description: "z.ai / BigModel GLM served over the OpenAI wire format",
146
+ protocol: "openai",
147
+ providerId: "glm-proxy",
148
+ baseUrl: "https://api.z.ai/api/paas/v4",
149
+ apiKeyEnv: "ZAI_API_KEY",
150
+ models: ["glm-5.2", "glm-5.1", "glm-5", "glm-4.6"],
151
+ defaultModel: "glm-5.2",
152
+ thinkingFormat: "zai",
153
+ },
154
+ {
155
+ id: "alibaba-token-plan",
156
+ aliases: ["alibaba", "token-plan", "dashscope-token-plan"],
157
+ label: "Alibaba Token Plan",
158
+ description: "Alibaba Token Plan OpenAI-compatible endpoint (Qwen / GLM / DeepSeek)",
159
+ protocol: "openai",
160
+ providerId: "alibaba-token-plan",
161
+ baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
162
+ apiKeyEnv: "ALIBABA_TOKEN_PLAN_API_KEY",
163
+ models: ["qwen3.8-max-preview", "qwen3.8-max", "glm-5.2", "deepseek-v4-pro"],
164
+ defaultModel: "qwen3.8-max",
165
+ thinkingFormat: "qwen",
166
+ },
167
+ {
168
+ id: "cline-pass",
169
+ aliases: ["clinepass", "cline"],
170
+ label: "ClinePass",
171
+ description: "ClinePass subscription endpoint with live /models discovery",
172
+ protocol: "openai",
173
+ providerId: "cline-pass",
174
+ baseUrl: "https://api.cline.bot/api/v1",
175
+ apiKeyEnv: "CLINE_API_KEY",
176
+ },
177
+ {
178
+ id: "commandcode-goat",
179
+ aliases: ["commandcode", "command-code", "goat"],
180
+ label: "Command Code GOAT",
181
+ description: "Command Code GOAT coding-plan endpoint with live /models discovery",
182
+ protocol: "openai",
183
+ providerId: "commandcode-goat",
184
+ baseUrl: "https://api.commandcode.ai/provider/v1",
185
+ apiKeyEnv: "CMD_API_KEY",
186
+ },
187
+ {
188
+ id: "ollama-cloud",
189
+ aliases: ["ollama-turbo"],
190
+ label: "Ollama Cloud",
191
+ description: "Ollama's hosted OpenAI-compatible endpoint (separate from a local ollama server)",
192
+ protocol: "openai",
193
+ providerId: "ollama-cloud",
194
+ baseUrl: "https://ollama.com/v1",
195
+ apiKeyEnv: "OLLAMA_API_KEY",
196
+ },
197
+ {
198
+ id: "github-copilot",
199
+ aliases: ["copilot", "gh-copilot"],
200
+ label: "GitHub Copilot",
201
+ description: "GitHub Copilot OpenAI-compatible endpoint (needs a Copilot token in the env var)",
202
+ protocol: "openai",
203
+ providerId: "github-copilot",
204
+ baseUrl: "https://api.githubcopilot.com",
205
+ apiKeyEnv: "GITHUB_COPILOT_TOKEN",
206
+ },
207
+ ] as const;
208
+
209
+ const BY_KEY = new Map<string, ProviderPreset>();
210
+ for (const preset of PROVIDER_PRESETS) {
211
+ BY_KEY.set(preset.id, preset);
212
+ for (const alias of preset.aliases) BY_KEY.set(alias, preset);
213
+ }
214
+
215
+ /** Resolve a preset by id or alias (case-insensitive). */
216
+ export function findProviderPreset(value: string | undefined): ProviderPreset | undefined {
217
+ const key = value?.trim().toLowerCase();
218
+ return key ? BY_KEY.get(key) : undefined;
219
+ }
220
+
221
+ /** Every preset id (canonical only) — used by autocomplete and error messages. */
222
+ export const PROVIDER_PRESET_IDS: readonly string[] = PROVIDER_PRESETS.map(p => p.id);
223
+
224
+ /** Rendered preset list for `/provider presets` and unknown-preset errors. */
225
+ export function formatProviderPresetList(): string[] {
226
+ return PROVIDER_PRESETS.map(p => {
227
+ const aliases = p.aliases.length ? ` (aliases: ${p.aliases.join(", ")})` : "";
228
+ const param = p.parameterized ? " [needs --base-url]" : "";
229
+ return ` ${p.id}${aliases}${param} — ${p.description}`;
230
+ });
231
+ }
232
+
233
+ export interface PresetExpandInput {
234
+ /** Preset id or alias. */
235
+ preset: string;
236
+ /** Override the provider id (defaults to the preset's `providerId`). */
237
+ id?: string;
238
+ /** Required for parameterized presets; rejected for fixed ones. */
239
+ baseUrl?: string;
240
+ /** Override the credential env var. */
241
+ apiKeyEnv?: string;
242
+ /** Literal key (stored in config, redacted on display). */
243
+ apiKey?: string;
244
+ /** Extra/override model ids. */
245
+ models?: readonly string[];
246
+ }
247
+
248
+ export interface PresetExpansion {
249
+ id: string;
250
+ config: CustomProviderConfig;
251
+ preset: ProviderPreset;
252
+ }
253
+
254
+ /**
255
+ * Expand a preset into a concrete provider id + config, validating the flag
256
+ * combination the way gajae-code's onboarding does (fixed presets reject
257
+ * `--base-url`; parameterized presets require it).
258
+ */
259
+ export function expandProviderPreset(input: PresetExpandInput): PresetExpansion {
260
+ const preset = findProviderPreset(input.preset);
261
+ if (!preset) {
262
+ throw new Error(
263
+ `Unknown provider preset '${input.preset}'. Available presets:\n${formatProviderPresetList().join("\n")}`,
264
+ );
265
+ }
266
+ if (preset.parameterized && !input.baseUrl?.trim()) {
267
+ throw new Error(`Preset '${preset.id}' needs your endpoint: pass --base-url <url>.`);
268
+ }
269
+ if (!preset.parameterized && input.baseUrl?.trim() && input.baseUrl.trim() !== preset.baseUrl) {
270
+ throw new Error(
271
+ `Preset '${preset.id}' pins ${preset.baseUrl}; drop --base-url, or register a fully custom provider with --compat ${preset.protocol}.`,
272
+ );
273
+ }
274
+ const baseUrl = input.baseUrl?.trim() || preset.baseUrl;
275
+ if (!baseUrl) throw new Error(`Preset '${preset.id}' has no base URL — pass --base-url <url>.`);
276
+ const models = input.models && input.models.length > 0 ? [...input.models] : preset.models ? [...preset.models] : undefined;
277
+ return {
278
+ id: (input.id?.trim() || preset.providerId).toLowerCase(),
279
+ preset,
280
+ config: {
281
+ label: preset.label,
282
+ baseUrl,
283
+ protocol: preset.protocol,
284
+ apiKeyEnv: input.apiKeyEnv?.trim() || preset.apiKeyEnv,
285
+ apiKey: input.apiKey?.trim() || undefined,
286
+ models,
287
+ defaultModel: preset.defaultModel ?? models?.[0],
288
+ thinkingFormat: preset.thinkingFormat,
289
+ preset: preset.id,
290
+ },
291
+ };
292
+ }