subconscious-cli 4.1.4 → 5.0.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/bin/models.js CHANGED
@@ -18,6 +18,27 @@ export function isLiveModelSource(source) {
18
18
  return source === 'available' || source === 'public';
19
19
  }
20
20
 
21
+ export function isAbortError(error) {
22
+ return error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
23
+ }
24
+
25
+ function linkAbortSignal(controller, signal) {
26
+ if (!signal) return () => {};
27
+ if (signal.aborted) {
28
+ controller.abort();
29
+ return () => {};
30
+ }
31
+ const onAbort = () => controller.abort();
32
+ signal.addEventListener('abort', onAbort, { once: true });
33
+ return () => signal.removeEventListener('abort', onAbort);
34
+ }
35
+
36
+ function abortError(message) {
37
+ const error = new Error(message);
38
+ error.name = 'AbortError';
39
+ return error;
40
+ }
41
+
21
42
  export function normalizeModelIds(modelIds = [], selectedModel) {
22
43
  const models = [];
23
44
  const seen = new Set();
@@ -53,6 +74,7 @@ export async function fetchGatewayModels({
53
74
  path = PUBLIC_MODELS_PATH,
54
75
  fetchImpl = globalThis.fetch,
55
76
  timeoutMs = DEFAULT_MODEL_FETCH_TIMEOUT_MS,
77
+ signal,
56
78
  }) {
57
79
  if (typeof fetchImpl !== 'function') {
58
80
  throw new Error('This Node.js version does not support fetch');
@@ -71,14 +93,26 @@ export async function fetchGatewayModels({
71
93
  const controller = new AbortController();
72
94
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
73
95
  timeout.unref?.();
96
+ const unlink = linkAbortSignal(controller, signal);
74
97
 
75
98
  try {
76
- const response = await fetchImpl(endpoint, {
77
- method: 'GET',
78
- headers,
79
- cache: 'no-store',
80
- signal: controller.signal,
99
+ if (signal?.aborted) throw abortError('Model discovery was cancelled');
100
+ const aborted = new Promise((_, reject) => {
101
+ const fail = () => {
102
+ if (signal?.aborted) reject(abortError('Model discovery was cancelled'));
103
+ else reject(new Error(`Model discovery timed out after ${timeoutMs}ms`));
104
+ };
105
+ controller.signal.addEventListener('abort', fail, { once: true });
81
106
  });
107
+ const response = await Promise.race([
108
+ fetchImpl(endpoint, {
109
+ method: 'GET',
110
+ headers,
111
+ cache: 'no-store',
112
+ signal: controller.signal,
113
+ }),
114
+ aborted,
115
+ ]);
82
116
 
83
117
  if (!response.ok) {
84
118
  throw new Error(`Model discovery returned HTTP ${response.status}`);
@@ -91,21 +125,24 @@ export async function fetchGatewayModels({
91
125
 
92
126
  return normalizeModelIds(payload.data.map((model) => model?.id));
93
127
  } catch (error) {
128
+ if (signal?.aborted) throw abortError('Model discovery was cancelled');
94
129
  if (controller.signal.aborted) {
95
130
  throw new Error(`Model discovery timed out after ${timeoutMs}ms`);
96
131
  }
97
132
  throw error;
98
133
  } finally {
134
+ unlink();
99
135
  clearTimeout(timeout);
100
136
  }
101
137
  }
102
138
 
103
- async function fetchPublicModels({ baseUrl, fetchImpl, timeoutMs }) {
139
+ async function fetchPublicModels({ baseUrl, fetchImpl, timeoutMs, signal }) {
104
140
  const models = await fetchGatewayModels({
105
141
  baseUrl,
106
142
  path: PUBLIC_MODELS_PATH,
107
143
  fetchImpl,
108
144
  timeoutMs,
145
+ signal,
109
146
  });
110
147
  if (!models.length) throw new Error('Model discovery returned no usable model IDs');
111
148
  return models;
@@ -134,6 +171,7 @@ export async function resolveModelCatalog({
134
171
  fallbackModels = [],
135
172
  fetchImpl = globalThis.fetch,
136
173
  timeoutMs = DEFAULT_MODEL_FETCH_TIMEOUT_MS,
174
+ signal,
137
175
  }) {
138
176
  const key = apiKey?.trim() || '';
139
177
 
@@ -145,22 +183,26 @@ export async function resolveModelCatalog({
145
183
  path: AVAILABLE_MODELS_PATH,
146
184
  fetchImpl,
147
185
  timeoutMs,
186
+ signal,
148
187
  });
149
188
  return liveCatalog(discovered, selectedModel, 'available');
150
189
  } catch (availableError) {
190
+ if (isAbortError(availableError) || signal?.aborted) throw availableError;
151
191
  try {
152
- const discovered = await fetchPublicModels({ baseUrl, fetchImpl, timeoutMs });
192
+ const discovered = await fetchPublicModels({ baseUrl, fetchImpl, timeoutMs, signal });
153
193
  return liveCatalog(discovered, selectedModel, 'public');
154
- } catch {
194
+ } catch (publicError) {
195
+ if (isAbortError(publicError) || signal?.aborted) throw publicError;
155
196
  return packagedCatalog(selectedModel, fallbackModels, availableError);
156
197
  }
157
198
  }
158
199
  }
159
200
 
160
201
  try {
161
- const discovered = await fetchPublicModels({ baseUrl, fetchImpl, timeoutMs });
202
+ const discovered = await fetchPublicModels({ baseUrl, fetchImpl, timeoutMs, signal });
162
203
  return liveCatalog(discovered, selectedModel, 'public');
163
204
  } catch (error) {
205
+ if (isAbortError(error) || signal?.aborted) throw error;
164
206
  return packagedCatalog(selectedModel, fallbackModels, error);
165
207
  }
166
208
  }
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,61 @@
1
+ import { modelSupportsVision } from './model-capabilities.js';
2
+
3
+ export const OPENCODE_PROVIDER_ID = 'subconscious';
4
+ export const OPENCODE_PROVIDER_NAME = 'Subconscious Gateway';
5
+
6
+ const ACRONYMS = new Set(['gpt', 'oss', 'api', 'gguf', 'ggml', 'nomic', 'vl', 'it', 'mlx']);
7
+
8
+ function formatToken(token) {
9
+ const lower = token.toLowerCase();
10
+ if (ACRONYMS.has(lower)) return token.toUpperCase();
11
+ if (/^\d+[bkmg]$/i.test(token)) return token.toUpperCase();
12
+ if (/^q\d+$/i.test(token)) return token.toUpperCase();
13
+ if (/^\d+\.\d+/.test(token)) return token;
14
+ if (/^[a-z]\d+[a-z]$/i.test(token) || /^\d+[a-z]$/i.test(token)) return token.toUpperCase();
15
+ return token.charAt(0).toUpperCase() + token.slice(1).toLowerCase();
16
+ }
17
+
18
+ /** Drop the subconscious/ prefix, then capitalize each dash-separated token. */
19
+ export function opencodeModelDisplayName(modelId) {
20
+ const text = String(modelId ?? '').trim();
21
+ const slug = text.startsWith('subconscious/') ? text.slice('subconscious/'.length) : text;
22
+ return slug.split(/[-_]/).filter(Boolean).map(formatToken).join(' ');
23
+ }
24
+
25
+ export function buildOpenCodeModels(modelIds, { context, output }) {
26
+ return Object.fromEntries(
27
+ modelIds.map((id) => [
28
+ id,
29
+ {
30
+ name: opencodeModelDisplayName(id),
31
+ tools: true,
32
+ limit: { context, output },
33
+ ...(modelSupportsVision(id)
34
+ ? { attachment: true, modalities: { input: ['text', 'image'], output: ['text'] } }
35
+ : {}),
36
+ },
37
+ ]),
38
+ );
39
+ }
40
+
41
+ export function buildOpenCodeConfig({ baseUrl, model, modelIds, context, output }) {
42
+ return {
43
+ $schema: 'https://opencode.ai/config.json',
44
+ disabled_providers: ['subconscious-cli'],
45
+ provider: {
46
+ [OPENCODE_PROVIDER_ID]: {
47
+ npm: '@ai-sdk/openai-compatible',
48
+ name: OPENCODE_PROVIDER_NAME,
49
+ whitelist: [...modelIds],
50
+ options: {
51
+ baseURL: `${baseUrl}/v1`,
52
+ apiKey: '{env:SUBCONSCIOUS_API_KEY}',
53
+ headers: { 'x-subconscious-client': 'opencode' },
54
+ modelsDiscovery: { enabled: false },
55
+ },
56
+ models: buildOpenCodeModels(modelIds, { context, output }),
57
+ },
58
+ },
59
+ model: `${OPENCODE_PROVIDER_ID}/${model}`,
60
+ };
61
+ }
@@ -216,6 +216,11 @@ export ANTHROPIC_DEFAULT_FABLE_MODEL="${ANTHROPIC_DEFAULT_FABLE_MODEL:-$FABLE_MO
216
216
  export ANTHROPIC_DEFAULT_FABLE_MODEL_NAME="${ANTHROPIC_DEFAULT_FABLE_MODEL_NAME:-$FABLE_MODEL_NAME}"
217
217
  export ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION="${ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION:-$FABLE_MODEL_DESCRIPTION}"
218
218
  export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY="${CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY:-0}"
219
+ # Gateway upstreams can't perform Anthropic's server-side auto mode classifier
220
+ # checks; opting out avoids the "isn't eligible for no-charge classifier
221
+ # requests" fallback and keeps classifier requests billed locally.
222
+ # https://code.claude.com/docs/en/auto-mode-classifier-billing
223
+ export CLAUDE_CODE_AUTO_MODE_SERVER="${CLAUDE_CODE_AUTO_MODE_SERVER:-0}"
219
224
  export CLAUDE_CODE_SUBAGENT_MODEL="${CLAUDE_CODE_SUBAGENT_MODEL:-$MODEL}"
220
225
  export CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS="${MAX_CONCURRENT_SUBAGENTS}"
221
226
  export CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH="${MAX_SUBAGENT_SPAWN_DEPTH}"
@@ -55,6 +55,7 @@ set -euo pipefail
55
55
 
56
56
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
57
57
  source "${SCRIPT_DIR}/../model-capabilities.generated.sh"
58
+ source "${SCRIPT_DIR}/provider.sh"
58
59
 
59
60
  # Load shared env from SUBC_ENV_FILE, or a sibling .env / env.example.
60
61
  SHARED_ENV="${SUBC_ENV_FILE:-${SCRIPT_DIR}/../.env}"
@@ -147,19 +148,7 @@ while IFS= read -r model_id; do
147
148
  add_supported_model "$model_id"
148
149
  done <<< "${SUBCONSCIOUS_MODELS:-$DEFAULT_SUBCONSCIOUS_MODELS}"
149
150
 
150
- MODELS_JSON=""
151
- for model_id in "${SUPPORTED_MODELS[@]}"; do
152
- vision_fields=""
153
- if subc_model_supports_vision "$model_id"; then
154
- vision_fields=',"attachment":true,"modalities":{"input":["text","image"],"output":["text"]}'
155
- fi
156
- model_json="\"${model_id}\":{\"name\":\"${model_id}\",\"tools\":true,\"limit\":{\"context\":${CONTEXT_LIMIT},\"output\":${OUTPUT_LIMIT}}${vision_fields}}"
157
- if [[ -n "$MODELS_JSON" ]]; then
158
- MODELS_JSON="${MODELS_JSON},${model_json}"
159
- else
160
- MODELS_JSON="$model_json"
161
- fi
162
- done
151
+ subc_opencode_build_models_json
163
152
 
164
153
  OPENCODE_DIR="${HOME}/.opencode"
165
154
  OPENCODE_CONFIG="${OPENCODE_DIR}/opencode.json"
@@ -186,34 +175,21 @@ require_cmds() {
186
175
  write_config() {
187
176
  mkdir -p "$OPENCODE_DIR"
188
177
  local base_url="${GATEWAY_URL%/}/v1"
189
- local provider
190
- provider=$(cat <<EOF
191
- {
192
- "npm": "@ai-sdk/openai-compatible",
193
- "name": "Subconscious Gateway",
194
- "options": {
195
- "baseURL": "${base_url}",
196
- "apiKey": "{env:SUBCONSCIOUS_API_KEY}",
197
- "headers": {
198
- "x-subconscious-client": "opencode"
199
- }
200
- },
201
- "models": {${MODELS_JSON}}
202
- }
203
- EOF
204
- )
178
+ local provider selected_model
179
+ provider="$(subc_opencode_build_provider_json "$base_url")"
180
+ selected_model="${SUBC_OPENCODE_PROVIDER_ID}/${MODEL}"
205
181
  if [[ -f "$OPENCODE_CONFIG" ]]; then
206
182
  local tmp
207
183
  tmp="$(mktemp)"
208
- jq --argjson provider "$provider" --arg model "subconscious/${MODEL}" '
184
+ jq --argjson provider "$provider" --arg model "$selected_model" --arg provider_id "$SUBC_OPENCODE_PROVIDER_ID" '
209
185
  .provider = (.provider // {})
210
- | .provider.subconscious = $provider
186
+ | .provider[$provider_id] = $provider
211
187
  | .model = $model
212
188
  ' "$OPENCODE_CONFIG" >"$tmp"
213
189
  mv "$tmp" "$OPENCODE_CONFIG"
214
190
  else
215
- jq -n --argjson provider "$provider" --arg model "subconscious/${MODEL}" \
216
- '{ "$schema": "https://opencode.ai/config.json", provider: {subconscious: $provider}, model: $model }' \
191
+ jq -n --argjson provider "$provider" --arg model "$selected_model" --arg provider_id "$SUBC_OPENCODE_PROVIDER_ID" \
192
+ '{ "$schema": "https://opencode.ai/config.json", provider: {($provider_id): $provider}, model: $model }' \
217
193
  >"$OPENCODE_CONFIG"
218
194
  fi
219
195
  local env_file="${OPENCODE_DIR}/subconscious.env"
@@ -240,7 +216,7 @@ uninstall_config() {
240
216
  local tmp
241
217
  tmp="$(mktemp)"
242
218
  jq '
243
- del(.provider.subconscious)
219
+ del(.provider.subconscious, .provider["subconscious-cli"])
244
220
  | if ((.model // "") | tostring | startswith("subconscious/")) then del(.model) else . end
245
221
  ' "$OPENCODE_CONFIG" >"$tmp"
246
222
  mv "$tmp" "$OPENCODE_CONFIG"
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env bash
2
+ # Shared OpenCode provider metadata for subc launch and install paths.
3
+
4
+ # OpenCode groups /models entries by provider.name. Keep this stable and human
5
+ # readable.
6
+ SUBC_OPENCODE_PROVIDER_ID="subconscious"
7
+ SUBC_OPENCODE_PROVIDER_NAME="Subconscious Gateway"
8
+
9
+ subc_opencode_model_display_name() {
10
+ local slug="${1:-}"
11
+ slug="${slug#subconscious/}"
12
+ echo "$slug" | awk -F '[-_]' '
13
+ function format_token(token, lower) {
14
+ lower = tolower(token)
15
+ if (lower ~ /^(gpt|oss|api|gguf|ggml|nomic|vl|it|mlx)$/) return toupper(token)
16
+ if (token ~ /^[0-9]+[bBkKmMgG]$/) return toupper(token)
17
+ if (token ~ /^[qQ][0-9]+$/) return toupper(token)
18
+ if (token ~ /^[0-9]+\.[0-9]+/) return token
19
+ if (token ~ /^[A-Za-z][0-9]+[A-Za-z]$/ || token ~ /^[0-9]+[A-Za-z]$/) return toupper(token)
20
+ return toupper(substr(token, 1, 1)) tolower(substr(token, 2))
21
+ }
22
+ {
23
+ out = ""
24
+ for (i = 1; i <= NF; i++) {
25
+ if ($i == "") continue
26
+ out = out (out == "" ? "" : " ") format_token($i)
27
+ }
28
+ print out
29
+ }
30
+ '
31
+ }
32
+
33
+ subc_opencode_build_whitelist_json() {
34
+ local model_id
35
+ WHITELIST_JSON=""
36
+ for model_id in "${SUPPORTED_MODELS[@]}"; do
37
+ if [[ -n "$WHITELIST_JSON" ]]; then
38
+ WHITELIST_JSON="${WHITELIST_JSON},\"${model_id}\""
39
+ else
40
+ WHITELIST_JSON="\"${model_id}\""
41
+ fi
42
+ done
43
+ WHITELIST_JSON="[${WHITELIST_JSON}]"
44
+ }
45
+
46
+ subc_opencode_build_models_json() {
47
+ local model_id display_name vision_fields model_json
48
+ MODELS_JSON=""
49
+ for model_id in "${SUPPORTED_MODELS[@]}"; do
50
+ display_name="$(subc_opencode_model_display_name "$model_id")"
51
+ vision_fields=""
52
+ if subc_model_supports_vision "$model_id"; then
53
+ vision_fields=',"attachment":true,"modalities":{"input":["text","image"],"output":["text"]}'
54
+ fi
55
+ model_json="\"${model_id}\":{\"name\":\"${display_name}\",\"tools\":true,\"limit\":{\"context\":${CONTEXT_LIMIT},\"output\":${OUTPUT_LIMIT}}${vision_fields}}"
56
+ if [[ -n "$MODELS_JSON" ]]; then
57
+ MODELS_JSON="${MODELS_JSON},${model_json}"
58
+ else
59
+ MODELS_JSON="$model_json"
60
+ fi
61
+ done
62
+ subc_opencode_build_whitelist_json
63
+ }
64
+
65
+ subc_opencode_build_config_json() {
66
+ local base_url="${1:?base url required}"
67
+ local model="${2:?model required}"
68
+ cat <<EOF
69
+ {"\$schema":"https://opencode.ai/config.json","disabled_providers":["subconscious-cli"],"provider":{"${SUBC_OPENCODE_PROVIDER_ID}":{"npm":"@ai-sdk/openai-compatible","name":"${SUBC_OPENCODE_PROVIDER_NAME}","whitelist":${WHITELIST_JSON},"options":{"baseURL":"${base_url}","apiKey":"{env:SUBCONSCIOUS_API_KEY}","headers":{"x-subconscious-client":"opencode"},"modelsDiscovery":{"enabled":false}},"models":{${MODELS_JSON}}}},"model":"${SUBC_OPENCODE_PROVIDER_ID}/${model}"}
70
+ EOF
71
+ }
72
+
73
+ subc_opencode_build_provider_json() {
74
+ local base_url="${1:?base url required}"
75
+ cat <<EOF
76
+ {
77
+ "npm": "@ai-sdk/openai-compatible",
78
+ "name": "${SUBC_OPENCODE_PROVIDER_NAME}",
79
+ "whitelist": ${WHITELIST_JSON},
80
+ "options": {
81
+ "baseURL": "${base_url}",
82
+ "apiKey": "{env:SUBCONSCIOUS_API_KEY}",
83
+ "headers": {
84
+ "x-subconscious-client": "opencode"
85
+ },
86
+ "modelsDiscovery": {
87
+ "enabled": false
88
+ }
89
+ },
90
+ "models": {${MODELS_JSON}}
91
+ }
92
+ EOF
93
+ }
@@ -19,6 +19,7 @@ set -euo pipefail
19
19
 
20
20
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
21
  source "${SCRIPT_DIR}/../model-capabilities.generated.sh"
22
+ source "${SCRIPT_DIR}/provider.sh"
22
23
 
23
24
  # Load shared env from SUBC_ENV_FILE, or a sibling .env / env.example.
24
25
  SHARED_ENV="${SUBC_ENV_FILE:-${SCRIPT_DIR}/../.env}"
@@ -58,19 +59,7 @@ while IFS= read -r model_id; do
58
59
  add_supported_model "$model_id"
59
60
  done <<< "${SUBCONSCIOUS_MODELS:-$DEFAULT_SUBCONSCIOUS_MODELS}"
60
61
 
61
- MODELS_JSON=""
62
- for model_id in "${SUPPORTED_MODELS[@]}"; do
63
- vision_fields=""
64
- if subc_model_supports_vision "$model_id"; then
65
- vision_fields=',"attachment":true,"modalities":{"input":["text","image"],"output":["text"]}'
66
- fi
67
- model_json="\"${model_id}\":{\"name\":\"${model_id}\",\"tools\":true,\"limit\":{\"context\":${CONTEXT_LIMIT},\"output\":${OUTPUT_LIMIT}}${vision_fields}}"
68
- if [[ -n "$MODELS_JSON" ]]; then
69
- MODELS_JSON="${MODELS_JSON},${model_json}"
70
- else
71
- MODELS_JSON="$model_json"
72
- fi
73
- done
62
+ subc_opencode_build_models_json
74
63
 
75
64
  if [[ -z "$GATEWAY_URL" || -z "$API_KEY" ]]; then
76
65
  echo "error: GATEWAY_URL and API_KEY must be set in ../.env" >&2
@@ -96,15 +85,11 @@ if [[ "${BASH_SOURCE[0]:-$0}" == "${0}" ]]; then
96
85
  fi
97
86
 
98
87
  BASE_URL="${GATEWAY_URL%/}/v1"
99
- RUNTIME_PROVIDER_ID="subconscious-cli"
100
88
 
101
89
  export SUBCONSCIOUS_API_KEY="$API_KEY"
102
90
  # Compaction reporting needs the plugin on disk; this launch path writes nothing.
103
91
  export SUBCONSCIOUS_GATEWAY_URL="${GATEWAY_URL%/}"
104
- export OPENCODE_CONFIG_CONTENT=$(cat <<EOF
105
- {"\$schema":"https://opencode.ai/config.json","disabled_providers":["subconscious"],"provider":{"${RUNTIME_PROVIDER_ID}":{"npm":"@ai-sdk/openai-compatible","name":"Subconscious Gateway","options":{"baseURL":"${BASE_URL}","apiKey":"{env:SUBCONSCIOUS_API_KEY}","headers":{"x-subconscious-client":"opencode"}},"models":{${MODELS_JSON}}}},"model":"${RUNTIME_PROVIDER_ID}/${MODEL}"}
106
- EOF
107
- )
92
+ export OPENCODE_CONFIG_CONTENT="$(subc_opencode_build_config_json "$BASE_URL" "$MODEL")"
108
93
 
109
94
  # If sourced, just export env and return.
110
95
  if [[ "${BASH_SOURCE[0]:-$0}" != "${0}" ]]; then