@xelauvas/xela-cli 0.2.3 → 0.3.1

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/bin/xela.js CHANGED
@@ -183,6 +183,18 @@ try {
183
183
  }
184
184
  } catch {}
185
185
 
186
+ // Ensure esbuild is available (may be missing in global installs)
187
+ try {
188
+ await import('esbuild');
189
+ } catch {
190
+ try {
191
+ execSync('npm install esbuild --no-save --no-audit --no-fund', {
192
+ cwd: INSTALL_DIR,
193
+ stdio: 'pipe',
194
+ });
195
+ } catch {}
196
+ }
197
+
186
198
  // Launch node with the TSX loader shim
187
199
  try {
188
200
  execFileSync(process.execPath, [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xelauvas/xela-cli",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "Xela — AI coding assistant powered by any model (OpenRouter, Groq, Ollama, DeepSeek, OpenAI)",
5
5
  "author": "xelauvas",
6
6
  "license": "MIT",
@@ -9,7 +9,17 @@
9
9
  "type": "git",
10
10
  "url": "https://github.com/xelauvas/codeclau.git"
11
11
  },
12
- "keywords": ["ai", "coding", "assistant", "cli", "openrouter", "groq", "ollama", "deepseek", "xela"],
12
+ "keywords": [
13
+ "ai",
14
+ "coding",
15
+ "assistant",
16
+ "cli",
17
+ "openrouter",
18
+ "groq",
19
+ "ollama",
20
+ "deepseek",
21
+ "xela"
22
+ ],
13
23
  "bin": {
14
24
  "xela": "./bin/xela.js"
15
25
  },
@@ -50,7 +60,7 @@
50
60
  "diff": "^7.0.0",
51
61
  "emoji-regex": "^10.4.0",
52
62
  "env-paths": "^3.0.0",
53
- "esbuild": "^0.27.4",
63
+ "esbuild": "^0.27.7",
54
64
  "execa": "^9.5.2",
55
65
  "figures": "^6.1.0",
56
66
  "fuse.js": "^7.1.0",
@@ -35,6 +35,7 @@
35
35
  * OPENAI_API_KEY=... OPENAI_BASE_URL=https://api.together.xyz/v1 OPENAI_MODEL=meta-llama/Llama-3.3-70B-Instruct-Turbo
36
36
  */
37
37
  import OpenAI from 'openai'
38
+ import { isClaudeModelName } from '../utils/model/providerModels.js'
38
39
 
39
40
  // Auto-detect provider from API key prefix or base URL
40
41
  function detectProvider(): { baseURL?: string; model: string; maxTokens: number } {
@@ -67,6 +68,18 @@ const PROVIDER = detectProvider()
67
68
  const MODEL = PROVIDER.model
68
69
  const MAX_TOKENS = PROVIDER.maxTokens
69
70
 
71
+ /**
72
+ * Resolve which model to send to the OpenAI-compatible provider.
73
+ * - Non-Claude names pass through directly (e.g. 'gpt-4o', 'deepseek-chat')
74
+ * - Claude names/aliases fall back to the provider default
75
+ * - Null/undefined uses the startup default
76
+ */
77
+ function resolveModel(paramsModel: string | undefined): string {
78
+ if (!paramsModel) return MODEL
79
+ if (isClaudeModelName(paramsModel)) return MODEL
80
+ return paramsModel
81
+ }
82
+
70
83
  let _openaiClient: OpenAI | null = null
71
84
 
72
85
  function getOpenAI(): OpenAI {
@@ -188,7 +201,7 @@ function mapStopReason(finishReason: string | null): string {
188
201
  async function* createAnthropicStyleStream(params: any, signal?: AbortSignal): AsyncGenerator<any> {
189
202
  const openai = getOpenAI()
190
203
  // Always use the configured MODEL — internal Claude model names don't exist on OpenAI-compatible providers
191
- const model = MODEL
204
+ const model = resolveModel(params.model)
192
205
 
193
206
  const openaiMessages: any[] = []
194
207
 
@@ -398,7 +411,7 @@ export function createOpenAIBackedAnthropicClient(): any {
398
411
  const nonStreamingResult = (async () => {
399
412
  const openai = getOpenAI()
400
413
  // Always use the configured MODEL
401
- const model = MODEL
414
+ const model = resolveModel(params.model)
402
415
 
403
416
  const openaiMessages: any[] = []
404
417
  if (params.system) {
@@ -14,7 +14,8 @@ import { MODEL_ALIASES } from '../../utils/model/aliases.js';
14
14
  import { checkOpus1mAccess, checkSonnet1mAccess } from '../../utils/model/check1mAccess.js';
15
15
  import { getDefaultMainLoopModelSetting, isOpus1mMergeEnabled, renderDefaultModelSetting } from '../../utils/model/model.js';
16
16
  import { isModelAllowed } from '../../utils/model/modelAllowlist.js';
17
- import { validateModel } from '../../utils/model/validateModel.js';
17
+ import { validateModel } from '../../utils/model/validateModel.js'
18
+ import { isOpenAIBackend, isClaudeModelName } from '../../utils/model/providerModels.js';
18
19
  function ModelPickerWrapper(t0) {
19
20
  const $ = _c(17);
20
21
  const {
@@ -141,13 +142,30 @@ function SetModelAndClose({
141
142
  const model = args === 'default' ? null : args;
142
143
  React.useEffect(() => {
143
144
  async function handleModelChange(): Promise<void> {
144
- if (model && !isModelAllowed(model)) {
145
+ if (model && !isOpenAIBackend() && !isModelAllowed(model)) {
145
146
  onDone(`Model '${model}' is not available. Your organization restricts model selection.`, {
146
147
  display: 'system'
147
148
  });
148
149
  return;
149
150
  }
150
151
 
152
+ // Skip validation for default model
153
+ if (!model) {
154
+ setModel(null);
155
+ return;
156
+ }
157
+
158
+ // OpenAI backend: skip all Claude-specific checks, go straight to validation
159
+ if (isOpenAIBackend()) {
160
+ const { valid, error: validationError } = await validateModel(model);
161
+ if (valid) {
162
+ setModel(model);
163
+ } else {
164
+ onDone(validationError || `Model '${model}' not found`, { display: 'system' });
165
+ }
166
+ return;
167
+ }
168
+
151
169
  // @[MODEL LAUNCH]: Update check for 1M access.
152
170
  if (model && isOpus1mUnavailable(model)) {
153
171
  onDone(`Opus 4.6 with 1M context is not available for your account. Learn more: https://code.claude.com/docs/en/model-config#extended-context-with-1m`, {
@@ -162,12 +180,6 @@ function SetModelAndClose({
162
180
  return;
163
181
  }
164
182
 
165
- // Skip validation for default model
166
- if (!model) {
167
- setModel(null);
168
- return;
169
- }
170
-
171
183
  // Skip validation for known aliases - they're predefined and should work
172
184
  if (isKnownAlias(model)) {
173
185
  setModel(model);
@@ -291,6 +303,9 @@ export const call: LocalJSXCommandCall = async (onDone, _context, args) => {
291
303
  return <ModelPickerWrapper onDone={onDone} />;
292
304
  };
293
305
  function renderModelLabel(model: string | null): string {
306
+ if (isOpenAIBackend() && model && !isClaudeModelName(model)) {
307
+ return model;
308
+ }
294
309
  const rendered = renderDefaultModelSetting(model ?? getDefaultMainLoopModelSetting());
295
310
  return model === null ? `${rendered} (default)` : rendered;
296
311
  }
@@ -18,6 +18,7 @@ import { Byline } from './design-system/Byline.js';
18
18
  import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js';
19
19
  import { Pane } from './design-system/Pane.js';
20
20
  import { effortLevelToSymbol } from './EffortIndicator.js';
21
+ import { isOpenAIBackend, fetchProviderModels, getOpenAIDefaultModel } from '../utils/model/providerModels.js';
21
22
  export type Props = {
22
23
  initial: string | null;
23
24
  sessionModel?: ModelSetting;
@@ -74,9 +75,38 @@ export function ModelPicker(t0) {
74
75
  t3 = $[3];
75
76
  }
76
77
  const modelOptions = t3;
78
+
79
+ const [providerModels, setProviderModels] = useState<Array<{value: string | null, label: string, description: string}>>([]);
80
+ const [providerLoading, setProviderLoading] = useState(isOpenAIBackend());
81
+
82
+ React.useEffect(() => {
83
+ if (!isOpenAIBackend()) return;
84
+ let cancelled = false;
85
+ (async () => {
86
+ try {
87
+ const models = await fetchProviderModels();
88
+ if (cancelled) return;
89
+ const defaultModel = getOpenAIDefaultModel();
90
+ const options = [
91
+ { value: null, label: `Default (${defaultModel})`, description: 'Current default model' },
92
+ ...models.map(m => ({ value: m.id, label: m.id, description: m.name !== m.id ? m.name : '' })),
93
+ ];
94
+ setProviderModels(options);
95
+ } finally {
96
+ if (!cancelled) setProviderLoading(false);
97
+ }
98
+ })();
99
+ return () => { cancelled = true; };
100
+ }, []);
101
+
102
+ // When on OpenAI backend and provider models loaded, use them instead of Claude options
103
+ const effectiveModelOptions = isOpenAIBackend() && providerModels.length > 0
104
+ ? providerModels
105
+ : modelOptions;
106
+
77
107
  let t4;
78
108
  bb0: {
79
- if (initial !== null && !modelOptions.some(opt => opt.value === initial)) {
109
+ if (initial !== null && !effectiveModelOptions.some(opt => opt.value === initial)) {
80
110
  let t5;
81
111
  if ($[4] !== initial) {
82
112
  t5 = modelDisplayString(initial);
@@ -100,7 +130,7 @@ export function ModelPicker(t0) {
100
130
  }
101
131
  let t7;
102
132
  if ($[9] !== modelOptions || $[10] !== t6) {
103
- t7 = [...modelOptions, t6];
133
+ t7 = [...effectiveModelOptions, t6];
104
134
  $[9] = modelOptions;
105
135
  $[10] = t6;
106
136
  $[11] = t7;
@@ -110,7 +140,7 @@ export function ModelPicker(t0) {
110
140
  t4 = t7;
111
141
  break bb0;
112
142
  }
113
- t4 = modelOptions;
143
+ t4 = effectiveModelOptions;
114
144
  }
115
145
  const optionsWithInitial = t4;
116
146
  let t5;
@@ -265,7 +295,7 @@ export function ModelPicker(t0) {
265
295
  } else {
266
296
  t15 = $[41];
267
297
  }
268
- const t16 = headerText ?? "Switch between Claude models. Applies to this session and future Claude Code sessions. For other/previous model names, specify with --model.";
298
+ const t16 = headerText ?? (providerLoading ? 'Loading models from provider...' : isOpenAIBackend() ? 'Switch between provider models. Select a model or use /model <name> directly.' : "Switch between Claude models. Applies to this session and future Claude Code sessions. For other/previous model names, specify with --model.");
269
299
  let t17;
270
300
  if ($[42] !== t16) {
271
301
  t17 = <Text dimColor={true}>{t16}</Text>;
@@ -74,7 +74,8 @@ export function getUserSpecifiedModelSetting(): ModelSetting | undefined {
74
74
  }
75
75
 
76
76
  // Ignore the user-specified model if it's not in the availableModels allowlist.
77
- if (specifiedModel && !isModelAllowed(specifiedModel)) {
77
+ // Skip this check on OpenAI backend — the allowlist is for Claude models only.
78
+ if (specifiedModel && !process.env.OPENAI_API_KEY && !isModelAllowed(specifiedModel)) {
78
79
  return undefined
79
80
  }
80
81
 
@@ -94,8 +95,12 @@ export function getUserSpecifiedModelSetting(): ModelSetting | undefined {
94
95
  * @returns The resolved model name to use
95
96
  */
96
97
  export function getMainLoopModel(): ModelName {
97
- // When using OpenAI backend, return the OpenAI model name directly
98
+ // When using OpenAI backend, respect user-selected model, fall back to OPENAI_MODEL
98
99
  if (process.env.OPENAI_API_KEY) {
100
+ const model = getUserSpecifiedModelSetting()
101
+ if (model !== undefined && model !== null) {
102
+ return model
103
+ }
99
104
  return process.env.OPENAI_MODEL || 'gpt-4o'
100
105
  }
101
106
  const model = getUserSpecifiedModelSetting()
@@ -160,9 +165,9 @@ export function getRuntimeMainLoopModel(params: {
160
165
  }): ModelName {
161
166
  const { permissionMode, mainLoopModel, exceeds200kTokens = false } = params
162
167
 
163
- // When using OpenAI backend, always use the configured model
168
+ // When using OpenAI backend, use the passed-in model (already resolved by caller)
164
169
  if (process.env.OPENAI_API_KEY) {
165
- return process.env.OPENAI_MODEL || 'gpt-4o'
170
+ return mainLoopModel || process.env.OPENAI_MODEL || 'gpt-4o'
166
171
  }
167
172
 
168
173
  // opusplan uses Opus in plan mode without [1m] suffix.
@@ -32,6 +32,7 @@ import {
32
32
  } from './model.js'
33
33
  import { has1mContext } from '../context.js'
34
34
  import { getGlobalConfig } from '../config.js'
35
+ import { isOpenAIBackend, getOpenAIDefaultModel, detectOpenAIProvider } from './providerModels.js'
35
36
 
36
37
  // @[MODEL LAUNCH]: Update all the available and default model option strings below.
37
38
 
@@ -266,6 +267,23 @@ function getOpusPlanOption(): ModelOption {
266
267
  }
267
268
  }
268
269
 
270
+ /**
271
+ * Build model options from the OpenAI-compatible provider's model list.
272
+ * Called synchronously with cached data if available, otherwise returns
273
+ * a minimal default option.
274
+ */
275
+ function getOpenAIModelOptions(): ModelOption[] {
276
+ const defaultModel = getOpenAIDefaultModel()
277
+ const provider = detectOpenAIProvider()
278
+ const providerLabel = provider === 'unknown' ? 'OpenAI-compatible' : provider.charAt(0).toUpperCase() + provider.slice(1)
279
+
280
+ return [{
281
+ value: null,
282
+ label: `Default (${defaultModel})`,
283
+ description: `${providerLabel} default model`,
284
+ }]
285
+ }
286
+
269
287
  // @[MODEL LAUNCH]: Update the model picker lists below to include/reorder options for the new model.
270
288
  // Each user tier (ant, Max/Team Premium, Pro/Team Standard/Enterprise, PAYG 1P, PAYG 3P) has its own list.
271
289
  function getModelOptionsBase(fastMode = false): ModelOption[] {
@@ -459,6 +477,9 @@ function getKnownModelOption(model: string): ModelOption | null {
459
477
  }
460
478
 
461
479
  export function getModelOptions(fastMode = false): ModelOption[] {
480
+ if (isOpenAIBackend()) {
481
+ return getOpenAIModelOptions()
482
+ }
462
483
  const options = getModelOptionsBase(fastMode)
463
484
 
464
485
  // Add the custom model from the ANTHROPIC_CUSTOM_MODEL_OPTION env var
@@ -0,0 +1,168 @@
1
+ import OpenAI from 'openai'
2
+ import { MODEL_ALIASES } from './aliases.js'
3
+
4
+ export type ProviderModel = {
5
+ id: string
6
+ name: string
7
+ }
8
+
9
+ export type ProviderName =
10
+ | 'openai'
11
+ | 'groq'
12
+ | 'openrouter'
13
+ | 'deepseek'
14
+ | 'mistral'
15
+ | 'together'
16
+ | 'ollama'
17
+ | 'fireworks'
18
+ | 'moonshot'
19
+ | 'unknown'
20
+
21
+ /**
22
+ * Returns true if the model name is a Claude model name or alias.
23
+ * These don't exist on OpenAI-compatible providers, so the adapter
24
+ * should fall back to the provider default.
25
+ */
26
+ export function isClaudeModelName(model: string): boolean {
27
+ const lower = model.toLowerCase().trim()
28
+ if (lower.includes('claude')) return true
29
+ return (MODEL_ALIASES as readonly string[]).includes(lower)
30
+ }
31
+
32
+ /**
33
+ * Detect which OpenAI-compatible provider is configured.
34
+ */
35
+ export function detectOpenAIProvider(): ProviderName {
36
+ const key = process.env.OPENAI_API_KEY || ''
37
+ const baseURL = process.env.OPENAI_BASE_URL || ''
38
+
39
+ if (baseURL.includes('groq.com') || key.startsWith('gsk_')) return 'groq'
40
+ if (baseURL.includes('openrouter.ai') || key.startsWith('sk-or-')) return 'openrouter'
41
+ if (baseURL.includes('deepseek.com')) return 'deepseek'
42
+ if (baseURL.includes('mistral.ai')) return 'mistral'
43
+ if (baseURL.includes('together.xyz')) return 'together'
44
+ if (baseURL.includes('localhost') || baseURL.includes('127.0.0.1')) return 'ollama'
45
+ if (baseURL.includes('fireworks.ai')) return 'fireworks'
46
+ if (baseURL.includes('moonshot.cn') || baseURL.includes('moonshot.ai')) return 'moonshot'
47
+ if (!baseURL || baseURL.includes('openai.com')) return 'openai'
48
+ return 'unknown'
49
+ }
50
+
51
+ const CURATED_MODELS: Record<ProviderName, ProviderModel[]> = {
52
+ openai: [
53
+ { id: 'gpt-4o', name: 'GPT-4o' },
54
+ { id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
55
+ { id: 'gpt-4.1', name: 'GPT-4.1' },
56
+ { id: 'gpt-4.1-mini', name: 'GPT-4.1 Mini' },
57
+ { id: 'o3', name: 'o3' },
58
+ { id: 'o4-mini', name: 'o4 Mini' },
59
+ ],
60
+ groq: [
61
+ { id: 'llama-3.3-70b-versatile', name: 'Llama 3.3 70B' },
62
+ { id: 'llama-3.1-8b-instant', name: 'Llama 3.1 8B Instant' },
63
+ { id: 'gemma2-9b-it', name: 'Gemma 2 9B' },
64
+ { id: 'mixtral-8x7b-32768', name: 'Mixtral 8x7B' },
65
+ ],
66
+ openrouter: [
67
+ { id: 'google/gemini-2.0-flash-001', name: 'Gemini 2.0 Flash' },
68
+ { id: 'openai/gpt-4o', name: 'GPT-4o' },
69
+ { id: 'meta-llama/llama-3.3-70b', name: 'Llama 3.3 70B' },
70
+ ],
71
+ deepseek: [
72
+ { id: 'deepseek-chat', name: 'DeepSeek Chat' },
73
+ { id: 'deepseek-reasoner', name: 'DeepSeek Reasoner' },
74
+ ],
75
+ mistral: [
76
+ { id: 'mistral-large-latest', name: 'Mistral Large' },
77
+ { id: 'mistral-small-latest', name: 'Mistral Small' },
78
+ { id: 'codestral-latest', name: 'Codestral' },
79
+ ],
80
+ together: [
81
+ { id: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', name: 'Llama 3.3 70B Turbo' },
82
+ { id: 'Qwen/Qwen2.5-72B-Instruct-Turbo', name: 'Qwen 2.5 72B Turbo' },
83
+ ],
84
+ ollama: [
85
+ { id: 'llama3.2', name: 'Llama 3.2' },
86
+ { id: 'qwen2.5-coder', name: 'Qwen 2.5 Coder' },
87
+ { id: 'deepseek-r1', name: 'DeepSeek R1' },
88
+ { id: 'mistral', name: 'Mistral' },
89
+ ],
90
+ fireworks: [
91
+ { id: 'accounts/fireworks/models/llama-v3p3-70b-instruct', name: 'Llama 3.3 70B' },
92
+ ],
93
+ moonshot: [
94
+ { id: 'moonshot-v1-32k', name: 'Moonshot V1 32K' },
95
+ { id: 'moonshot-v1-128k', name: 'Moonshot V1 128K' },
96
+ ],
97
+ unknown: [],
98
+ }
99
+
100
+ export function getCuratedModels(provider: ProviderName): ProviderModel[] {
101
+ return CURATED_MODELS[provider] ?? []
102
+ }
103
+
104
+ let cachedModels: ProviderModel[] | null = null
105
+
106
+ /**
107
+ * Fetch all available models from the provider via GET /v1/models.
108
+ * Results are cached in memory for the session.
109
+ * Returns curated fallback list on failure.
110
+ */
111
+ export async function fetchProviderModels(): Promise<ProviderModel[]> {
112
+ if (cachedModels !== null) {
113
+ return cachedModels
114
+ }
115
+
116
+ const provider = detectOpenAIProvider()
117
+
118
+ try {
119
+ const client = new OpenAI({
120
+ apiKey: process.env.OPENAI_API_KEY,
121
+ ...(process.env.OPENAI_BASE_URL ? { baseURL: process.env.OPENAI_BASE_URL } : {}),
122
+ ...(provider === 'groq' && !process.env.OPENAI_BASE_URL
123
+ ? { baseURL: 'https://api.groq.com/openai/v1' }
124
+ : {}),
125
+ ...(provider === 'openrouter' && !process.env.OPENAI_BASE_URL
126
+ ? { baseURL: 'https://openrouter.ai/api/v1' }
127
+ : {}),
128
+ })
129
+
130
+ const response = await client.models.list({}, { timeout: 5000 })
131
+ const models: ProviderModel[] = []
132
+
133
+ for await (const model of response) {
134
+ models.push({ id: model.id, name: model.id })
135
+ }
136
+
137
+ models.sort((a, b) => a.id.localeCompare(b.id))
138
+ cachedModels = models
139
+ return models
140
+ } catch {
141
+ const fallback = getCuratedModels(provider)
142
+ cachedModels = fallback
143
+ return fallback
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Check if a model ID exists in the cached provider model list.
149
+ * Returns false if the cache hasn't been populated yet.
150
+ */
151
+ export function isKnownProviderModel(modelId: string): boolean {
152
+ if (!cachedModels) return false
153
+ return cachedModels.some(m => m.id === modelId)
154
+ }
155
+
156
+ /**
157
+ * Returns true if the app is using an OpenAI-compatible backend.
158
+ */
159
+ export function isOpenAIBackend(): boolean {
160
+ return !!process.env.OPENAI_API_KEY
161
+ }
162
+
163
+ /**
164
+ * Get the startup default model for the current OpenAI provider.
165
+ */
166
+ export function getOpenAIDefaultModel(): string {
167
+ return process.env.OPENAI_MODEL || 'gpt-4o'
168
+ }
@@ -10,6 +10,7 @@ import {
10
10
  AuthenticationError,
11
11
  } from '@anthropic-ai/sdk'
12
12
  import { getModelStrings } from './modelStrings.js'
13
+ import { isOpenAIBackend, isKnownProviderModel, isClaudeModelName } from './providerModels.js'
13
14
 
14
15
  // Cache valid models to avoid repeated API calls
15
16
  const validModelCache = new Map<string, boolean>()
@@ -27,6 +28,24 @@ export async function validateModel(
27
28
  return { valid: false, error: 'Model name cannot be empty' }
28
29
  }
29
30
 
31
+ // OpenAI-compatible backend: skip Anthropic validation
32
+ if (isOpenAIBackend()) {
33
+ // Claude names don't exist on OpenAI providers
34
+ if (isClaudeModelName(normalizedModel)) {
35
+ return {
36
+ valid: false,
37
+ error: `'${normalizedModel}' is a Claude model name. Use a model name from your provider (e.g. 'gpt-4o', 'deepseek-chat').`,
38
+ }
39
+ }
40
+ // If we already fetched the model list, check against it
41
+ if (isKnownProviderModel(normalizedModel)) {
42
+ return { valid: true }
43
+ }
44
+ // Otherwise, trust the user — the adapter will try it and the provider
45
+ // will return an error if it doesn't exist
46
+ return { valid: true }
47
+ }
48
+
30
49
  // Check against availableModels allowlist before any API call
31
50
  if (!isModelAllowed(normalizedModel)) {
32
51
  return {