claude-connect 0.1.7 → 0.1.8

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
@@ -83,6 +83,7 @@ Al activar:
83
83
  - `Inception Labs` usa el gateway local sobre `https://api.inceptionlabs.ai/v1/chat/completions`
84
84
  - `OpenRouter` usa `openrouter/free` por gateway sobre `https://openrouter.ai/api/v1`
85
85
  - `Qwen` apunta al gateway local `http://127.0.0.1:4310/anthropic`
86
+ - para algunos modelos con limites conocidos, el gateway ahora ajusta `max_tokens` y bloquea prompts sobredimensionados antes de que el upstream devuelva errores opacos
86
87
 
87
88
  ## Providers
88
89
 
@@ -123,6 +124,8 @@ Nota sobre `OpenAI`:
123
124
  Nota sobre `Inception Labs`:
124
125
 
125
126
  - esta primera integracion expone solo `mercury-2`, que es el modelo chat-compatible oficial en `v1/chat/completions`
127
+ - `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
128
+ - Claude Connect aplica presupuesto preventivo de contexto para `mercury-2` usando ventana `128K` y salida maxima `16,384`
126
129
  - `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
130
  - autenticacion soportada: `API key`
128
131
  - referencias oficiales:
@@ -130,6 +133,13 @@ Nota sobre `Inception Labs`:
130
133
  - https://docs.inceptionlabs.ai/get-started/authentication
131
134
  - https://docs.inceptionlabs.ai/get-started/models
132
135
 
136
+ Nota sobre `DeepSeek`:
137
+
138
+ - Claude Connect aplica presupuesto preventivo de contexto para `deepseek-chat` y `deepseek-reasoner`
139
+ - referencias oficiales:
140
+ - https://api-docs.deepseek.com/quick_start/pricing/
141
+ - https://api-docs.deepseek.com/guides/reasoning_model
142
+
133
143
  Nota sobre `Ollama`:
134
144
 
135
145
  - la URL del servidor se define al crear la conexión
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-connect",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
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
  );
@@ -703,6 +704,7 @@ const seedProviders = [
703
704
  apiBaseUrl: 'https://api.inceptionlabs.ai/v1',
704
705
  apiPath: '/chat/completions',
705
706
  authEnvMode: 'auth_token',
707
+ supportsVision: false,
706
708
  sortOrder: 1,
707
709
  isDefault: 1
708
710
  }
@@ -838,10 +840,10 @@ function seedCatalog(db) {
838
840
  INSERT INTO models (
839
841
  id, provider_id, name, category, context_window, summary,
840
842
  upstream_model_id,
841
- transport_mode, api_style, api_base_url, api_path, auth_env_mode,
843
+ transport_mode, api_style, api_base_url, api_path, auth_env_mode, supports_vision,
842
844
  sort_order, is_default
843
845
  )
844
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
846
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
845
847
  ON CONFLICT(id) DO UPDATE SET
846
848
  provider_id = excluded.provider_id,
847
849
  name = excluded.name,
@@ -854,6 +856,7 @@ function seedCatalog(db) {
854
856
  api_base_url = excluded.api_base_url,
855
857
  api_path = excluded.api_path,
856
858
  auth_env_mode = excluded.auth_env_mode,
859
+ supports_vision = excluded.supports_vision,
857
860
  sort_order = excluded.sort_order,
858
861
  is_default = excluded.is_default
859
862
  `);
@@ -924,6 +927,7 @@ function seedCatalog(db) {
924
927
  model.apiBaseUrl ?? null,
925
928
  model.apiPath ?? null,
926
929
  model.authEnvMode ?? 'auth_token',
930
+ model.supportsVision === false ? 0 : 1,
927
931
  model.sortOrder,
928
932
  model.isDefault
929
933
  );
@@ -995,6 +999,10 @@ function ensureSchemaMigrations(db) {
995
999
  alterStatements.push(`ALTER TABLE models ADD COLUMN auth_env_mode TEXT NOT NULL DEFAULT 'auth_token'`);
996
1000
  }
997
1001
 
1002
+ if (!modelColumns.has('supports_vision')) {
1003
+ alterStatements.push(`ALTER TABLE models ADD COLUMN supports_vision INTEGER NOT NULL DEFAULT 1`);
1004
+ }
1005
+
998
1006
  if (!modelColumns.has('upstream_model_id')) {
999
1007
  alterStatements.push(`ALTER TABLE models ADD COLUMN upstream_model_id TEXT`);
1000
1008
  }
@@ -1037,6 +1045,7 @@ function mapModelRow(row) {
1037
1045
  apiBaseUrl: row.api_base_url,
1038
1046
  apiPath: row.api_path,
1039
1047
  authEnvMode: row.auth_env_mode,
1048
+ supportsVision: Boolean(row.supports_vision),
1040
1049
  sortOrder: Number(row.sort_order),
1041
1050
  isDefault: Boolean(row.is_default)
1042
1051
  };
@@ -24,6 +24,7 @@ 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';
29
30
  import { readManagedProviderTokenSecret, readManagedTokenSecret } from '../lib/secrets.js';
@@ -161,6 +162,48 @@ function getUpstreamModelId(profile) {
161
162
  return profile?.model?.upstreamModelId ?? profile?.model?.id ?? 'unknown';
162
163
  }
163
164
 
165
+ function requestContainsImageInput(body) {
166
+ return Array.isArray(body?.messages)
167
+ && body.messages.some((messageItem) => Array.isArray(messageItem?.content)
168
+ && messageItem.content.some((part) => part?.type === 'image'));
169
+ }
170
+
171
+ function profileSupportsImageInput(profile) {
172
+ if (typeof profile?.model?.supportsVision === 'boolean') {
173
+ return profile.model.supportsVision;
174
+ }
175
+
176
+ if (profile?.provider?.id === 'inception') {
177
+ return false;
178
+ }
179
+
180
+ return true;
181
+ }
182
+
183
+ function stringifyUpstreamMessage(value) {
184
+ if (typeof value === 'string') {
185
+ return value;
186
+ }
187
+
188
+ if (value == null) {
189
+ return '';
190
+ }
191
+
192
+ if (typeof value === 'object') {
193
+ if ('message' in value && typeof value.message === 'string') {
194
+ return value.message;
195
+ }
196
+
197
+ try {
198
+ return JSON.stringify(value);
199
+ } catch (_error) {
200
+ return String(value);
201
+ }
202
+ }
203
+
204
+ return String(value);
205
+ }
206
+
164
207
  function resolveGatewayUpstreamConfig(profile) {
165
208
  if (profile?.provider?.id === 'ollama') {
166
209
  return {
@@ -335,9 +378,9 @@ async function forwardUpstreamRequest({ targetUrl, headers, payload, context, re
335
378
  });
336
379
  }
337
380
 
338
- const message = responsePayload?.error?.message
339
- || responsePayload?.message
340
- || responsePayload?.error
381
+ const message = stringifyUpstreamMessage(responsePayload?.error?.message)
382
+ || stringifyUpstreamMessage(responsePayload?.message)
383
+ || stringifyUpstreamMessage(responsePayload?.error)
341
384
  || `HTTP ${response.status}`;
342
385
  const providerName = context?.profile?.provider?.name ?? context?.profile?.provider?.id ?? 'El proveedor';
343
386
  const containsImageInput = Array.isArray(payload?.messages)
@@ -453,9 +496,20 @@ async function handleCountTokens(request, response) {
453
496
  }
454
497
 
455
498
  async function handleMessages(request, response) {
456
- const body = await readJsonBody(request);
499
+ const rawBody = await readJsonBody(request);
457
500
  const context = await resolveGatewayContext();
458
501
 
502
+ if (requestContainsImageInput(rawBody) && !profileSupportsImageInput(context.profile)) {
503
+ const providerName = context.profile.provider.name;
504
+ const modelName = context.profile.model.name;
505
+ throw new Error(`${providerName} no admite imagenes con el modelo ${modelName} en esta integracion. Usa un proveedor o modelo con soporte visual.`);
506
+ }
507
+
508
+ const body = enforceModelTokenBudget({
509
+ body: rawBody,
510
+ profile: context.profile
511
+ });
512
+
459
513
  if (context.upstreamApiStyle === 'anthropic') {
460
514
  const upstreamResponse = await forwardAnthropicMessage({
461
515
  requestBody: body,
@@ -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
@@ -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 = () => {