claude-connect 0.1.7 → 0.1.9

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/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Claude Connect
2
2
 
3
- > Conecta `Claude Code` con `OpenCode Go`, `Zen`, `Kimi`, `DeepSeek`, `Ollama`, `OpenAI`, `Inception Labs`, `OpenRouter` y `Qwen` desde una interfaz de consola clara, rápida y reversible.
3
+ > Conecta `Claude Code` con `OpenCode Go`, `Zen`, `Kimi`, `DeepSeek`, `Z.AI`, `Ollama`, `OpenAI`, `Inception Labs`, `OpenRouter` y `Qwen` desde una interfaz de consola clara, rápida y reversible.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/claude-connect?style=for-the-badge&logo=npm&color=cb3837)](https://www.npmjs.com/package/claude-connect)
6
6
  [![node](https://img.shields.io/badge/node-%3E%3D22-2f7d32?style=for-the-badge&logo=node.js&logoColor=white)](https://nodejs.org/)
7
7
  [![license](https://img.shields.io/badge/license-MIT-0f172a?style=for-the-badge)](./LICENSE)
8
- [![providers](https://img.shields.io/badge/providers-OpenCode%20Go%20%7C%20Zen%20%7C%20Kimi%20%7C%20DeepSeek%20%7C%20Ollama%20%7C%20OpenAI%20%7C%20Inception%20Labs%20%7C%20OpenRouter%20%7C%20Qwen-0ea5e9?style=for-the-badge)](https://www.npmjs.com/package/claude-connect)
8
+ [![providers](https://img.shields.io/badge/providers-OpenCode%20Go%20%7C%20Zen%20%7C%20Kimi%20%7C%20DeepSeek%20%7C%20Z.AI%20%7C%20Ollama%20%7C%20OpenAI%20%7C%20Inception%20Labs%20%7C%20OpenRouter%20%7C%20Qwen-0ea5e9?style=for-the-badge)](https://www.npmjs.com/package/claude-connect)
9
9
 
10
10
  ## Why Claude Connect
11
11
 
@@ -13,7 +13,7 @@
13
13
 
14
14
  ### Highlights
15
15
 
16
- - `OpenCode Go`, `Zen`, `Kimi`, `DeepSeek`, `Ollama`, `OpenAI`, `Inception Labs`, `OpenRouter` y `Qwen` listos desde el primer arranque
16
+ - `OpenCode Go`, `Zen`, `Kimi`, `DeepSeek`, `Z.AI`, `Ollama`, `OpenAI`, `Inception Labs`, `OpenRouter` y `Qwen` listos desde el primer arranque
17
17
  - soporte para `Token` y `OAuth` cuando el proveedor lo permite
18
18
  - API keys compartidas por proveedor para no repetir el mismo token en cada modelo
19
19
  - activación reversible sobre la instalación real de `Claude Code`
@@ -78,11 +78,14 @@ Al activar:
78
78
  - `Zen` usa conexión directa o gateway según el modelo elegido
79
79
  - `Kimi` usa gateway local y reenvia al endpoint Anthropic de `https://api.kimi.com/coding/`
80
80
  - `DeepSeek` apunta a `https://api.deepseek.com/anthropic`
81
+ - `Z.AI` apunta a `https://api.z.ai/api/anthropic`
81
82
  - `Ollama` pide una URL local o remota, valida `/api/tags` y usa el gateway local sobre `.../api/chat`
82
83
  - `OpenAI` usa el gateway local sobre `https://api.openai.com/v1/chat/completions`
83
84
  - `Inception Labs` usa el gateway local sobre `https://api.inceptionlabs.ai/v1/chat/completions`
84
85
  - `OpenRouter` usa `openrouter/free` por gateway sobre `https://openrouter.ai/api/v1`
85
86
  - `Qwen` apunta al gateway local `http://127.0.0.1:4310/anthropic`
87
+ - para algunos modelos con limites conocidos, el gateway ahora ajusta `max_tokens` y bloquea prompts sobredimensionados antes de que el upstream devuelva errores opacos
88
+ - para `Inception Labs`, el gateway tambien respeta un presupuesto local de input tokens por minuto para reducir errores de `Rate limit reached`
86
89
 
87
90
  ## Providers
88
91
 
@@ -92,6 +95,7 @@ Al activar:
92
95
  | `Zen` | `Claude*` de Zen + modelos `chat/completions` de Zen | `Token` | Mixta |
93
96
  | `Kimi` | `kimi-for-coding` | `Token` | Gateway local |
94
97
  | `DeepSeek` | `deepseek-chat`, `deepseek-reasoner` | `Token` | Directa |
98
+ | `Z.AI` | `glm-5.1`, `glm-4.7`, `glm-4.5-air` | `Token` | Directa |
95
99
  | `Ollama` | modelos descubiertos desde tu servidor | `Servidor Ollama` | Gateway local |
96
100
  | `OpenAI` | `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.3-codex`, `gpt-5.2-codex`, `gpt-5.2`, `gpt-5.1-codex-max`, `gpt-5.1-codex-mini` | `Token` | Gateway local |
97
101
  | `Inception Labs` | `mercury-2` | `Token` | Gateway local |
@@ -123,12 +127,31 @@ Nota sobre `OpenAI`:
123
127
  Nota sobre `Inception Labs`:
124
128
 
125
129
  - esta primera integracion expone solo `mercury-2`, que es el modelo chat-compatible oficial en `v1/chat/completions`
130
+ - `mercury-2` se trata como modelo solo texto en Claude Connect; si envias una imagen, la app ahora corta la peticion con un mensaje claro
131
+ - Claude Connect aplica presupuesto preventivo de contexto para `mercury-2` usando ventana `128K` y salida maxima `16,384`
132
+ - Claude Connect tambien aplica una ventana deslizante local de `400,000` input tokens por minuto para reducir rechazos del upstream por rate limit
126
133
  - `Mercury Edit 2` no se publica todavia en Claude Connect porque usa endpoints `fim/edit` que no encajan con Claude Code en esta arquitectura
127
134
  - autenticacion soportada: `API key`
128
135
  - referencias oficiales:
129
136
  - https://docs.inceptionlabs.ai/get-started/get-started
130
137
  - https://docs.inceptionlabs.ai/get-started/authentication
131
138
  - https://docs.inceptionlabs.ai/get-started/models
139
+ - https://docs.inceptionlabs.ai/get-started/rate-limits
140
+
141
+ Nota sobre `DeepSeek`:
142
+
143
+ - Claude Connect aplica presupuesto preventivo de contexto para `deepseek-chat` y `deepseek-reasoner`
144
+ - referencias oficiales:
145
+ - https://api-docs.deepseek.com/quick_start/pricing/
146
+ - https://api-docs.deepseek.com/guides/reasoning_model
147
+
148
+ Nota sobre `Z.AI`:
149
+
150
+ - usa el endpoint Anthropic-compatible oficial `https://api.z.ai/api/anthropic`
151
+ - Claude Connect fija `API_TIMEOUT_MS=3000000`
152
+ - al activar un perfil de `Z.AI`, tambien mapea `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL` y `ANTHROPIC_DEFAULT_OPUS_MODEL` al modelo elegido para que Claude Code use `GLM` de forma consistente
153
+ - referencias oficiales:
154
+ - https://docs.z.ai/devpack/tool/claude
132
155
 
133
156
  Nota sobre `Ollama`:
134
157
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-connect",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "CLI para configurar Claude Code con proveedores de modelos externos",
5
5
  "author": "wmcarlosv",
6
6
  "type": "module",
@@ -39,6 +39,7 @@ CREATE TABLE IF NOT EXISTS models (
39
39
  api_base_url TEXT,
40
40
  api_path TEXT,
41
41
  auth_env_mode TEXT NOT NULL DEFAULT 'auth_token',
42
+ supports_vision INTEGER NOT NULL DEFAULT 1,
42
43
  sort_order INTEGER NOT NULL DEFAULT 0,
43
44
  is_default INTEGER NOT NULL DEFAULT 0
44
45
  );
@@ -527,6 +528,75 @@ const seedProviders = [
527
528
  }
528
529
  ]
529
530
  },
531
+ {
532
+ id: 'zai',
533
+ name: 'Z.AI',
534
+ vendor: 'Zhipu AI',
535
+ description: 'GLM Coding Plan para Claude Code usando el endpoint Anthropic-compatible oficial de z.ai.',
536
+ docsUrl: 'https://docs.z.ai/devpack/tool/claude',
537
+ docsVerifiedAt: '2026-04-04',
538
+ baseUrl: 'https://api.z.ai/api/anthropic',
539
+ defaultModelId: 'glm-5.1',
540
+ defaultAuthMethodId: 'token',
541
+ defaultApiKeyEnvVar: 'ZAI_API_KEY',
542
+ models: [
543
+ {
544
+ id: 'glm-5.1',
545
+ name: 'GLM-5.1',
546
+ category: 'Coding',
547
+ contextWindow: 'Auto',
548
+ summary: 'Modelo recomendado de la documentacion oficial de z.ai para usuarios Max que quieren usar GLM-5.1 en Claude Code.',
549
+ upstreamModelId: 'glm-5.1',
550
+ transportMode: 'direct',
551
+ apiStyle: 'anthropic',
552
+ apiBaseUrl: 'https://api.z.ai/api/anthropic',
553
+ apiPath: '/v1/messages',
554
+ authEnvMode: 'auth_token',
555
+ sortOrder: 1,
556
+ isDefault: 1
557
+ },
558
+ {
559
+ id: 'glm-4.7',
560
+ name: 'GLM-4.7',
561
+ category: 'General',
562
+ contextWindow: 'Auto',
563
+ summary: 'Modelo default recomendado por z.ai para Opus y Sonnet dentro del GLM Coding Plan.',
564
+ upstreamModelId: 'glm-4.7',
565
+ transportMode: 'direct',
566
+ apiStyle: 'anthropic',
567
+ apiBaseUrl: 'https://api.z.ai/api/anthropic',
568
+ apiPath: '/v1/messages',
569
+ authEnvMode: 'auth_token',
570
+ sortOrder: 2,
571
+ isDefault: 0
572
+ },
573
+ {
574
+ id: 'glm-4.5-air',
575
+ name: 'GLM-4.5-Air',
576
+ category: 'Fast',
577
+ contextWindow: 'Auto',
578
+ summary: 'Modelo ligero recomendado por z.ai para la clase Haiku dentro del GLM Coding Plan.',
579
+ upstreamModelId: 'glm-4.5-air',
580
+ transportMode: 'direct',
581
+ apiStyle: 'anthropic',
582
+ apiBaseUrl: 'https://api.z.ai/api/anthropic',
583
+ apiPath: '/v1/messages',
584
+ authEnvMode: 'auth_token',
585
+ sortOrder: 3,
586
+ isDefault: 0
587
+ }
588
+ ],
589
+ authMethods: [
590
+ {
591
+ id: 'token',
592
+ name: 'Token',
593
+ description: 'Conexion por API key contra el endpoint Anthropic-compatible oficial de z.ai.',
594
+ credentialKind: 'env_var',
595
+ sortOrder: 1,
596
+ isDefault: 1
597
+ }
598
+ ]
599
+ },
530
600
  {
531
601
  id: 'ollama',
532
602
  name: 'Ollama',
@@ -703,6 +773,7 @@ const seedProviders = [
703
773
  apiBaseUrl: 'https://api.inceptionlabs.ai/v1',
704
774
  apiPath: '/chat/completions',
705
775
  authEnvMode: 'auth_token',
776
+ supportsVision: false,
706
777
  sortOrder: 1,
707
778
  isDefault: 1
708
779
  }
@@ -838,10 +909,10 @@ function seedCatalog(db) {
838
909
  INSERT INTO models (
839
910
  id, provider_id, name, category, context_window, summary,
840
911
  upstream_model_id,
841
- transport_mode, api_style, api_base_url, api_path, auth_env_mode,
912
+ transport_mode, api_style, api_base_url, api_path, auth_env_mode, supports_vision,
842
913
  sort_order, is_default
843
914
  )
844
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
915
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
845
916
  ON CONFLICT(id) DO UPDATE SET
846
917
  provider_id = excluded.provider_id,
847
918
  name = excluded.name,
@@ -854,6 +925,7 @@ function seedCatalog(db) {
854
925
  api_base_url = excluded.api_base_url,
855
926
  api_path = excluded.api_path,
856
927
  auth_env_mode = excluded.auth_env_mode,
928
+ supports_vision = excluded.supports_vision,
857
929
  sort_order = excluded.sort_order,
858
930
  is_default = excluded.is_default
859
931
  `);
@@ -924,6 +996,7 @@ function seedCatalog(db) {
924
996
  model.apiBaseUrl ?? null,
925
997
  model.apiPath ?? null,
926
998
  model.authEnvMode ?? 'auth_token',
999
+ model.supportsVision === false ? 0 : 1,
927
1000
  model.sortOrder,
928
1001
  model.isDefault
929
1002
  );
@@ -995,6 +1068,10 @@ function ensureSchemaMigrations(db) {
995
1068
  alterStatements.push(`ALTER TABLE models ADD COLUMN auth_env_mode TEXT NOT NULL DEFAULT 'auth_token'`);
996
1069
  }
997
1070
 
1071
+ if (!modelColumns.has('supports_vision')) {
1072
+ alterStatements.push(`ALTER TABLE models ADD COLUMN supports_vision INTEGER NOT NULL DEFAULT 1`);
1073
+ }
1074
+
998
1075
  if (!modelColumns.has('upstream_model_id')) {
999
1076
  alterStatements.push(`ALTER TABLE models ADD COLUMN upstream_model_id TEXT`);
1000
1077
  }
@@ -1037,6 +1114,7 @@ function mapModelRow(row) {
1037
1114
  apiBaseUrl: row.api_base_url,
1038
1115
  apiPath: row.api_path,
1039
1116
  authEnvMode: row.auth_env_mode,
1117
+ supportsVision: Boolean(row.supports_vision),
1040
1118
  sortOrder: Number(row.sort_order),
1041
1119
  isDefault: Boolean(row.is_default)
1042
1120
  };
@@ -24,8 +24,10 @@ import {
24
24
  } from './state.js';
25
25
  import { resolveClaudeConnectPaths } from '../lib/app-paths.js';
26
26
  import { readSwitchState } from '../lib/claude-settings.js';
27
+ import { enforceModelTokenBudget } from '../lib/model-budget.js';
27
28
  import { readOAuthToken, refreshOAuthToken } from '../lib/oauth.js';
28
29
  import { readProfileFile } from '../lib/profile.js';
30
+ import { reserveProviderInputTokens } from '../lib/provider-rate-limit.js';
29
31
  import { readManagedProviderTokenSecret, readManagedTokenSecret } from '../lib/secrets.js';
30
32
 
31
33
  const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
@@ -161,6 +163,48 @@ function getUpstreamModelId(profile) {
161
163
  return profile?.model?.upstreamModelId ?? profile?.model?.id ?? 'unknown';
162
164
  }
163
165
 
166
+ function requestContainsImageInput(body) {
167
+ return Array.isArray(body?.messages)
168
+ && body.messages.some((messageItem) => Array.isArray(messageItem?.content)
169
+ && messageItem.content.some((part) => part?.type === 'image'));
170
+ }
171
+
172
+ function profileSupportsImageInput(profile) {
173
+ if (typeof profile?.model?.supportsVision === 'boolean') {
174
+ return profile.model.supportsVision;
175
+ }
176
+
177
+ if (profile?.provider?.id === 'inception') {
178
+ return false;
179
+ }
180
+
181
+ return true;
182
+ }
183
+
184
+ function stringifyUpstreamMessage(value) {
185
+ if (typeof value === 'string') {
186
+ return value;
187
+ }
188
+
189
+ if (value == null) {
190
+ return '';
191
+ }
192
+
193
+ if (typeof value === 'object') {
194
+ if ('message' in value && typeof value.message === 'string') {
195
+ return value.message;
196
+ }
197
+
198
+ try {
199
+ return JSON.stringify(value);
200
+ } catch (_error) {
201
+ return String(value);
202
+ }
203
+ }
204
+
205
+ return String(value);
206
+ }
207
+
164
208
  function resolveGatewayUpstreamConfig(profile) {
165
209
  if (profile?.provider?.id === 'ollama') {
166
210
  return {
@@ -335,9 +379,9 @@ async function forwardUpstreamRequest({ targetUrl, headers, payload, context, re
335
379
  });
336
380
  }
337
381
 
338
- const message = responsePayload?.error?.message
339
- || responsePayload?.message
340
- || responsePayload?.error
382
+ const message = stringifyUpstreamMessage(responsePayload?.error?.message)
383
+ || stringifyUpstreamMessage(responsePayload?.message)
384
+ || stringifyUpstreamMessage(responsePayload?.error)
341
385
  || `HTTP ${response.status}`;
342
386
  const providerName = context?.profile?.provider?.name ?? context?.profile?.provider?.id ?? 'El proveedor';
343
387
  const containsImageInput = Array.isArray(payload?.messages)
@@ -453,9 +497,24 @@ async function handleCountTokens(request, response) {
453
497
  }
454
498
 
455
499
  async function handleMessages(request, response) {
456
- const body = await readJsonBody(request);
500
+ const rawBody = await readJsonBody(request);
457
501
  const context = await resolveGatewayContext();
458
502
 
503
+ if (requestContainsImageInput(rawBody) && !profileSupportsImageInput(context.profile)) {
504
+ const providerName = context.profile.provider.name;
505
+ const modelName = context.profile.model.name;
506
+ throw new Error(`${providerName} no admite imagenes con el modelo ${modelName} en esta integracion. Usa un proveedor o modelo con soporte visual.`);
507
+ }
508
+
509
+ const body = enforceModelTokenBudget({
510
+ body: rawBody,
511
+ profile: context.profile
512
+ });
513
+ await reserveProviderInputTokens({
514
+ profile: context.profile,
515
+ inputTokens: estimateTokenCountFromAnthropicRequest(body)
516
+ });
517
+
459
518
  if (context.upstreamApiStyle === 'anthropic') {
460
519
  const upstreamResponse = await forwardAnthropicMessage({
461
520
  requestBody: body,
@@ -154,6 +154,13 @@ export async function resolveClaudeTransportForProfile({
154
154
  extraEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL = profile.model.id;
155
155
  }
156
156
 
157
+ if (profile.provider.id === 'zai') {
158
+ extraEnv.API_TIMEOUT_MS = '3000000';
159
+ extraEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL = profile.model.id;
160
+ extraEnv.ANTHROPIC_DEFAULT_SONNET_MODEL = profile.model.id;
161
+ extraEnv.ANTHROPIC_DEFAULT_OPUS_MODEL = profile.model.id;
162
+ }
163
+
157
164
  if (profile.provider.id === 'kimi') {
158
165
  extraEnv.ENABLE_TOOL_SEARCH = 'false';
159
166
  }
@@ -220,6 +227,8 @@ export function buildClaudeSettingsForProfile({
220
227
  delete env.ENABLE_TOOL_SEARCH;
221
228
  delete env.ANTHROPIC_MODEL;
222
229
  delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
230
+ delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
231
+ delete env.ANTHROPIC_DEFAULT_OPUS_MODEL;
223
232
 
224
233
  Object.assign(env, extraEnv);
225
234
 
@@ -259,9 +268,16 @@ export async function activateClaudeProfile({ profile, gatewayBaseUrl = 'http://
259
268
  const currentAccount = await readClaudeAccount();
260
269
  const currentCredentials = await readJsonIfExists(claudeCredentialsPath);
261
270
  const currentState = await readSwitchState();
262
- const originalSettings = currentState?.originalSettings ?? currentSettings;
263
- const originalAccount = currentState?.originalAccount ?? currentAccount;
264
- const originalCredentials = currentState && Object.prototype.hasOwnProperty.call(currentState, 'originalCredentials')
271
+ const canReuseActiveSnapshot = currentState?.active === true;
272
+ const originalSettings = canReuseActiveSnapshot
273
+ ? currentState?.originalSettings ?? currentSettings
274
+ : currentSettings;
275
+ const originalAccount = canReuseActiveSnapshot
276
+ ? currentState?.originalAccount ?? currentAccount
277
+ : currentAccount;
278
+ const originalCredentials = canReuseActiveSnapshot
279
+ && currentState
280
+ && Object.prototype.hasOwnProperty.call(currentState, 'originalCredentials')
265
281
  ? currentState.originalCredentials
266
282
  : currentCredentials;
267
283
  const transport = await resolveClaudeTransportForProfile({
@@ -0,0 +1,73 @@
1
+ import { estimateTokenCountFromAnthropicRequest } from '../gateway/messages.js';
2
+
3
+ function getModelIdentity(profile) {
4
+ return profile?.model?.upstreamModelId ?? profile?.model?.id ?? '';
5
+ }
6
+
7
+ export function getModelTokenLimits(profile) {
8
+ const providerId = profile?.provider?.id;
9
+ const modelId = getModelIdentity(profile);
10
+
11
+ if (providerId === 'inception' && modelId === 'mercury-2') {
12
+ return {
13
+ contextWindowTokens: 128_000,
14
+ defaultOutputTokens: 8_192,
15
+ maxOutputTokens: 16_384
16
+ };
17
+ }
18
+
19
+ if (providerId === 'deepseek' && modelId === 'deepseek-chat') {
20
+ return {
21
+ contextWindowTokens: 128_000,
22
+ defaultOutputTokens: 4_000,
23
+ maxOutputTokens: 8_000
24
+ };
25
+ }
26
+
27
+ if (providerId === 'deepseek' && modelId === 'deepseek-reasoner') {
28
+ return {
29
+ contextWindowTokens: 128_000,
30
+ defaultOutputTokens: 32_000,
31
+ maxOutputTokens: 64_000
32
+ };
33
+ }
34
+
35
+ return null;
36
+ }
37
+
38
+ export function enforceModelTokenBudget({ body, profile, safetyMarginTokens = 1024 }) {
39
+ const limits = getModelTokenLimits(profile);
40
+
41
+ if (!limits) {
42
+ return body;
43
+ }
44
+
45
+ const estimatedInputTokens = estimateTokenCountFromAnthropicRequest(body);
46
+ const availableForOutput = limits.contextWindowTokens - estimatedInputTokens - safetyMarginTokens;
47
+
48
+ if (availableForOutput <= 0) {
49
+ throw new Error(
50
+ `La conversacion actual excede el contexto aproximado de ${limits.contextWindowTokens.toLocaleString('en-US')} tokens para ${profile.model.name}. Usa /compact o /clear antes de continuar.`
51
+ );
52
+ }
53
+
54
+ const requestedOutputTokens = typeof body?.max_tokens === 'number'
55
+ ? body.max_tokens
56
+ : limits.defaultOutputTokens;
57
+ const clampedOutputTokens = Math.min(
58
+ requestedOutputTokens,
59
+ limits.maxOutputTokens,
60
+ availableForOutput
61
+ );
62
+
63
+ if (clampedOutputTokens < 256) {
64
+ throw new Error(
65
+ `Queda muy poco margen de salida para ${profile.model.name} dentro de su contexto aproximado de ${limits.contextWindowTokens.toLocaleString('en-US')} tokens. Usa /compact o /clear antes de continuar.`
66
+ );
67
+ }
68
+
69
+ return {
70
+ ...body,
71
+ max_tokens: clampedOutputTokens
72
+ };
73
+ }
@@ -32,7 +32,8 @@ export function buildProfile({ provider, model, authMethod, profileName, apiKeyE
32
32
  apiStyle: model.apiStyle,
33
33
  apiBaseUrl: model.apiBaseUrl,
34
34
  apiPath: model.apiPath,
35
- authEnvMode: model.authEnvMode
35
+ authEnvMode: model.authEnvMode,
36
+ supportsVision: model.supportsVision ?? true
36
37
  },
37
38
  auth: {
38
39
  method: authMethod.id
@@ -0,0 +1,109 @@
1
+ function getProviderId(profile) {
2
+ return profile?.provider?.id ?? '';
3
+ }
4
+
5
+ function getProviderInputTokensPerMinute(profile) {
6
+ const providerId = getProviderId(profile);
7
+
8
+ if (providerId === 'inception') {
9
+ return 400_000;
10
+ }
11
+
12
+ return null;
13
+ }
14
+
15
+ const recentReservations = [];
16
+
17
+ function pruneReservations(now) {
18
+ for (let index = recentReservations.length - 1; index >= 0; index -= 1) {
19
+ if (now - recentReservations[index].timestamp >= 60_000) {
20
+ recentReservations.splice(index, 1);
21
+ }
22
+ }
23
+ }
24
+
25
+ function sumReservedTokens(profile, now) {
26
+ const providerId = getProviderId(profile);
27
+
28
+ return recentReservations.reduce((total, entry) => {
29
+ if (entry.providerId !== providerId) {
30
+ return total;
31
+ }
32
+
33
+ if (now - entry.timestamp >= 60_000) {
34
+ return total;
35
+ }
36
+
37
+ return total + entry.tokens;
38
+ }, 0);
39
+ }
40
+
41
+ function earliestExpiryForProvider(profile, now) {
42
+ const providerId = getProviderId(profile);
43
+ let minExpiry = null;
44
+
45
+ for (const entry of recentReservations) {
46
+ if (entry.providerId !== providerId) {
47
+ continue;
48
+ }
49
+
50
+ const expiry = entry.timestamp + 60_000;
51
+
52
+ if (expiry <= now) {
53
+ continue;
54
+ }
55
+
56
+ if (minExpiry == null || expiry < minExpiry) {
57
+ minExpiry = expiry;
58
+ }
59
+ }
60
+
61
+ return minExpiry;
62
+ }
63
+
64
+ export function resetProviderRateLimitState() {
65
+ recentReservations.length = 0;
66
+ }
67
+
68
+ export async function reserveProviderInputTokens({
69
+ profile,
70
+ inputTokens,
71
+ now = () => Date.now(),
72
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
73
+ }) {
74
+ const tokensPerMinute = getProviderInputTokensPerMinute(profile);
75
+
76
+ if (!tokensPerMinute) {
77
+ return;
78
+ }
79
+
80
+ if (inputTokens > tokensPerMinute) {
81
+ throw new Error(
82
+ `${profile.provider.name} rechazo la solicitud porque la entrada estimada excede el limite de ${tokensPerMinute.toLocaleString('en-US')} tokens por minuto. Usa /compact o /clear antes de continuar.`
83
+ );
84
+ }
85
+
86
+ while (true) {
87
+ const currentTime = now();
88
+ pruneReservations(currentTime);
89
+ const usedTokens = sumReservedTokens(profile, currentTime);
90
+
91
+ if (usedTokens + inputTokens <= tokensPerMinute) {
92
+ recentReservations.push({
93
+ providerId: getProviderId(profile),
94
+ tokens: inputTokens,
95
+ timestamp: currentTime
96
+ });
97
+ return;
98
+ }
99
+
100
+ const nextExpiry = earliestExpiryForProvider(profile, currentTime);
101
+
102
+ if (nextExpiry == null) {
103
+ throw new Error(`No se pudo reservar presupuesto de tokens para ${profile.provider.name}.`);
104
+ }
105
+
106
+ const waitMs = Math.max(250, nextExpiry - currentTime + 25);
107
+ await sleep(waitMs);
108
+ }
109
+ }
@@ -19,7 +19,26 @@ export function openAppScreen() {
19
19
  }
20
20
 
21
21
  export function closeAppScreen() {
22
- process.stdout.write('\x1b[?25h\x1b[?1049l');
22
+ process.stdout.write('\x1b[?25h\x1b[?1049l\r\n');
23
+ }
24
+
25
+ function beginKeyboardCapture() {
26
+ readline.emitKeypressEvents(process.stdin);
27
+ process.stdin.resume();
28
+ process.stdin.setRawMode(true);
29
+ }
30
+
31
+ function restoreKeyboardState(onKeypress) {
32
+ if (typeof onKeypress === 'function') {
33
+ process.stdin.removeListener('keypress', onKeypress);
34
+ }
35
+
36
+ if (process.stdin.isTTY && process.stdin.isRaw) {
37
+ process.stdin.setRawMode(false);
38
+ }
39
+
40
+ process.stdin.pause();
41
+ process.stdout.write('\x1b[?25h');
23
42
  }
24
43
 
25
44
  export function clearScreen() {
@@ -64,13 +83,11 @@ export function buildFrame({ eyebrow, title, subtitle, body = [], footer = [] })
64
83
 
65
84
  export function waitForAnyKey(message = 'Presiona una tecla para continuar.') {
66
85
  return new Promise((resolve, reject) => {
67
- readline.emitKeypressEvents(process.stdin);
68
- process.stdin.setRawMode(true);
86
+ beginKeyboardCapture();
69
87
  let escapePending = false;
70
88
 
71
89
  const cleanup = () => {
72
- process.stdin.removeListener('keypress', onKeypress);
73
- process.stdin.setRawMode(false);
90
+ restoreKeyboardState(onKeypress);
74
91
  };
75
92
 
76
93
  const onKeypress = (_input, key = {}) => {
@@ -124,12 +141,10 @@ export function selectFromList({
124
141
  let selectedIndex = 0;
125
142
  let escapePending = false;
126
143
 
127
- readline.emitKeypressEvents(process.stdin);
128
- process.stdin.setRawMode(true);
144
+ beginKeyboardCapture();
129
145
 
130
146
  const cleanup = () => {
131
- process.stdin.removeListener('keypress', onKeypress);
132
- process.stdin.setRawMode(false);
147
+ restoreKeyboardState(onKeypress);
133
148
  };
134
149
 
135
150
  const render = () => {
@@ -247,12 +262,10 @@ export function promptText({
247
262
  let value = '';
248
263
  let escapePending = false;
249
264
 
250
- readline.emitKeypressEvents(process.stdin);
251
- process.stdin.setRawMode(true);
265
+ beginKeyboardCapture();
252
266
 
253
267
  const cleanup = () => {
254
- process.stdin.removeListener('keypress', onKeypress);
255
- process.stdin.setRawMode(false);
268
+ restoreKeyboardState(onKeypress);
256
269
  };
257
270
 
258
271
  const render = () => {