@aikdna/kdna-studio-cli 0.11.0 → 0.13.0-rc.components.2

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/src/llm/config.js DELETED
@@ -1,98 +0,0 @@
1
- // LLM Provider Configuration
2
- // Sources (priority order): env vars > ~/.kdna/config.json > defaults
3
- const fs = require('fs');
4
- const path = require('path');
5
- const os = require('os');
6
- const { canonicalLlmBaseUrl } = require('./transport');
7
-
8
- const ENV_PREFIX = 'KDNA_LLM_';
9
-
10
- const WELL_KNOWN = {
11
- openai: { baseURL: 'https://api.openai.com/v1', models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'] },
12
- anthropic: { baseURL: 'https://api.anthropic.com/v1', models: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-latest'] },
13
- deepseek: { baseURL: 'https://api.deepseek.com/v1', models: ['deepseek-chat', 'deepseek-reasoner'] },
14
- openrouter: { baseURL: 'https://openrouter.ai/api/v1', models: ['openai/gpt-4o', 'anthropic/claude-sonnet-4', 'deepseek/deepseek-chat'] },
15
- gemini: { baseURL: 'https://generativelanguage.googleapis.com/v1beta', models: ['gemini-2.5-flash', 'gemini-2.5-pro'] },
16
- perplexity: { baseURL: 'https://api.perplexity.ai', models: ['sonar-pro', 'sonar'] },
17
- groq: { baseURL: 'https://api.groq.com/openai/v1', models: ['llama-4-scout-17b-16e-instruct', 'deepseek-r1-distill-llama-70b'] },
18
- local: { baseURL: 'http://127.0.0.1:11434/v1', models: ['llama3.2'] },
19
- ollama: { baseURL: 'http://127.0.0.1:11434/v1', models: ['llama3.2'] },
20
- openai_compatible: { baseURL: null, models: [] },
21
- };
22
-
23
- function loadConfig() {
24
- const config = { provider: null, apiKey: null, model: null, baseURL: null, temperature: 0.7, maxTokens: 4096 };
25
-
26
- // 1. Read from ~/.kdna/config.json
27
- const configPath = path.join(os.homedir(), '.kdna', 'config.json');
28
- try { if (fs.existsSync(configPath)) Object.assign(config, JSON.parse(fs.readFileSync(configPath, 'utf8')).llm || {}); } catch (e) { console.error('Error reading LLM config file:', e.message); }
29
-
30
- // 2. Env vars override
31
- if (process.env.KDNA_LLM_PROVIDER) config.provider = process.env.KDNA_LLM_PROVIDER;
32
- if (process.env.KDNA_LLM_API_KEY) config.apiKey = process.env.KDNA_LLM_API_KEY;
33
- if (process.env.KDNA_LLM_MODEL) config.model = process.env.KDNA_LLM_MODEL;
34
- if (process.env.KDNA_LLM_BASE_URL) config.baseURL = process.env.KDNA_LLM_BASE_URL;
35
- if (process.env.KDNA_LLM_TEMPERATURE) config.temperature = parseFloat(process.env.KDNA_LLM_TEMPERATURE);
36
- if (process.env.KDNA_LLM_MAX_TOKENS) config.maxTokens = parseInt(process.env.KDNA_LLM_MAX_TOKENS, 10);
37
-
38
- // 3. Resolve well-known provider
39
- if (config.provider && WELL_KNOWN[config.provider]) {
40
- const wk = WELL_KNOWN[config.provider];
41
- if (wk.baseURL && !config.baseURL) config.baseURL = wk.baseURL;
42
- if (wk.models.length > 0 && !config.model) config.model = wk.models[0];
43
- }
44
-
45
- // 4. Provider-specific env var (KDNA_LLM_API_KEY_<PROVIDER>)
46
- if (!config.apiKey && config.provider) {
47
- const keyEnv = `KDNA_LLM_API_KEY_${config.provider.toUpperCase().replace(/-/g, '_')}`;
48
- if (process.env[keyEnv]) config.apiKey = process.env[keyEnv];
49
- }
50
-
51
- // 5. Provider-native fallback — each provider only uses its own native env var
52
- if (!config.apiKey && config.provider) {
53
- const PROVIDER_NATIVE_KEYS = {
54
- openai: 'OPENAI_API_KEY',
55
- anthropic: 'ANTHROPIC_API_KEY',
56
- deepseek: 'DEEPSEEK_API_KEY',
57
- groq: 'GROQ_API_KEY',
58
- gemini: 'GEMINI_API_KEY',
59
- perplexity: 'PERPLEXITY_API_KEY',
60
- openrouter: 'OPENROUTER_API_KEY',
61
- };
62
- const nativeEnv = PROVIDER_NATIVE_KEYS[config.provider];
63
- if (nativeEnv && process.env[nativeEnv]) config.apiKey = process.env[nativeEnv];
64
- }
65
-
66
- return config;
67
- }
68
-
69
- function saveConfig(updates) {
70
- const safeUpdates = { ...updates };
71
- if (Object.prototype.hasOwnProperty.call(safeUpdates, 'baseURL')) {
72
- safeUpdates.baseURL = canonicalLlmBaseUrl(safeUpdates.baseURL);
73
- }
74
- const configPath = path.join(os.homedir(), '.kdna', 'config.json');
75
- let existing = {};
76
- try { if (fs.existsSync(configPath)) existing = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch (e) { console.error('Error reading config file for save:', e.message); }
77
- existing.llm = { ...(existing.llm || {}), ...safeUpdates };
78
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
79
- fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n');
80
- }
81
-
82
- function validateConfig(config) {
83
- const errors = [];
84
- if (!config.provider) errors.push('Missing KDNA_LLM_PROVIDER. Set via env var or ~/.kdna/config.json');
85
- if (!config.apiKey) errors.push('Missing KDNA_LLM_API_KEY');
86
- if (!config.model) errors.push('Missing KDNA_LLM_MODEL');
87
- if (!config.baseURL) errors.push('Missing KDNA_LLM_BASE_URL');
88
- else {
89
- try {
90
- canonicalLlmBaseUrl(config.baseURL);
91
- } catch {
92
- errors.push('Invalid KDNA_LLM_BASE_URL: external providers require HTTPS; HTTP is loopback-only');
93
- }
94
- }
95
- return { valid: errors.length === 0, errors };
96
- }
97
-
98
- module.exports = { loadConfig, saveConfig, validateConfig, WELL_KNOWN };
package/src/llm/index.js DELETED
@@ -1,50 +0,0 @@
1
- // KDNA Studio CLI — unified LLM interface
2
- // Supports OpenAI-compatible and Anthropic APIs
3
- // Configuration via KDNA_LLM_* env vars or ~/.kdna/config.json
4
- const { loadConfig, saveConfig, validateConfig } = require('./config');
5
- const openai = require('./providers/openai');
6
- const anthropic = require('./providers/anthropic');
7
-
8
- function getProvider(config) {
9
- if (!config || !config.provider) throw new Error('LLM not configured. Run: kdna-studio llm config');
10
- if (config.provider === 'anthropic') return anthropic;
11
- return openai; // All OpenAI-compatible providers use the same API
12
- }
13
-
14
- async function chat(messages, options = {}) {
15
- const config = loadConfig();
16
- if (options.provider) config.provider = options.provider;
17
- if (options.model) config.model = options.model;
18
- if (options.apiKey) config.apiKey = options.apiKey;
19
- if (options.baseURL) config.baseURL = options.baseURL;
20
-
21
- const validation = validateConfig(config);
22
- if (!validation.valid) throw new Error(`LLM configuration error:\n${validation.errors.map(e => ` - ${e}`).join('\n')}`);
23
-
24
- const provider = getProvider(config);
25
- return provider.chat(config, messages, options);
26
- }
27
-
28
- async function chatWithJSON(messages, options = {}) {
29
- const result = await chat(messages, { ...options, responseFormat: { type: 'json_object' } });
30
- try {
31
- result.data = JSON.parse(result.content);
32
- } catch {
33
- console.error('Error parsing LLM JSON response.');
34
- // Try to extract JSON from markdown code blocks
35
- const match = result.content.match(/```(?:json)?\s*([\s\S]*?)```/);
36
- if (match) {
37
- try { result.data = JSON.parse(match[1].trim()); } catch { console.error('Error parsing JSON from markdown code block.'); }
38
- }
39
- }
40
- return result;
41
- }
42
-
43
- function config() { return loadConfig(); }
44
-
45
- function configure(updates) {
46
- saveConfig(updates);
47
- return loadConfig();
48
- }
49
-
50
- module.exports = { chat, chatWithJSON, config, configure };
@@ -1,41 +0,0 @@
1
- // Anthropic Messages API provider
2
-
3
- const { postLlmJson } = require('../transport');
4
-
5
- async function chat(config, messages, options = {}) {
6
- const model = options.model || config.model;
7
- const maxTokens = options.maxTokens || config.maxTokens || 4096;
8
-
9
- // Convert OpenAI-format messages to Anthropic format
10
- const systemMessages = messages.filter(m => m.role === 'system').map(m => m.content).join('\n\n');
11
- const conversationMessages = messages.filter(m => m.role !== 'system').map(m => ({
12
- role: m.role === 'assistant' ? 'assistant' : 'user',
13
- content: m.content,
14
- }));
15
-
16
- const body = { model, max_tokens: maxTokens, messages: conversationMessages };
17
- if (systemMessages) body.system = systemMessages;
18
-
19
- const headers = {
20
- 'Content-Type': 'application/json',
21
- 'x-api-key': config.apiKey,
22
- 'anthropic-version': '2023-06-01',
23
- };
24
-
25
- const data = await postLlmJson({
26
- baseUrl: config.baseURL,
27
- endpoint: '/messages',
28
- headers,
29
- body,
30
- });
31
- const content = data.content?.[0]?.text;
32
- if (!content) throw new Error('Anthropic returned empty response');
33
-
34
- return {
35
- content: content.trim(),
36
- model: data.model || model,
37
- usage: data.usage || null,
38
- };
39
- }
40
-
41
- module.exports = { chat };
@@ -1,42 +0,0 @@
1
- // OpenAI-compatible chat completions provider
2
- // Works with: OpenAI, DeepSeek, OpenRouter, Groq, Ollama, LM Studio, Perplexity, and any OpenAI-compatible API
3
-
4
- const { postLlmJson } = require('../transport');
5
-
6
- async function chat(config, messages, options = {}) {
7
- const model = options.model || config.model;
8
- const temperature = options.temperature ?? config.temperature ?? 0.7;
9
- const maxTokens = options.maxTokens || config.maxTokens || 4096;
10
- const responseFormat = options.responseFormat || null;
11
-
12
- const body = { model, messages, temperature, max_tokens: maxTokens };
13
- if (responseFormat) body.response_format = responseFormat;
14
-
15
- const headers = {
16
- 'Content-Type': 'application/json',
17
- 'Authorization': `Bearer ${config.apiKey}`,
18
- };
19
-
20
- // Provider-specific headers
21
- if (config.provider === 'openrouter') {
22
- headers['HTTP-Referer'] = 'https://github.com/aikdna/kdna-studio-cli';
23
- headers['X-Title'] = 'KDNA Studio CLI';
24
- }
25
-
26
- const data = await postLlmJson({
27
- baseUrl: config.baseURL,
28
- endpoint: '/chat/completions',
29
- headers,
30
- body,
31
- });
32
- const content = data.choices?.[0]?.message?.content;
33
- if (!content) throw new Error('LLM returned empty response');
34
-
35
- return {
36
- content: content.trim(),
37
- model: data.model || model,
38
- usage: data.usage || null,
39
- };
40
- }
41
-
42
- module.exports = { chat };
@@ -1,196 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * Transport boundary for supported AI authoring providers.
5
- *
6
- * Requests contain provider credentials plus source evidence, interview
7
- * answers, or other judgment-authoring material. External providers must use
8
- * HTTPS. Plain HTTP is limited to exact numeric loopback development hosts;
9
- * localhost, LAN addresses, and arbitrary hostnames are not exceptions.
10
- */
11
-
12
- const MAX_LLM_URL_BYTES = 2048;
13
- const MAX_LLM_RESPONSE_BYTES = 4 * 1024 * 1024;
14
- const ALLOWED_ENDPOINTS = new Set(['/chat/completions', '/messages']);
15
-
16
- class LlmTransportError extends Error {
17
- constructor(code, message, status = null) {
18
- super(message);
19
- this.name = 'LlmTransportError';
20
- this.code = code;
21
- this.status = Number.isInteger(status) ? status : null;
22
- }
23
- }
24
-
25
- function isExactLoopback(hostname) {
26
- return hostname === '127.0.0.1' || hostname === '[::1]';
27
- }
28
-
29
- function canonicalLlmBaseUrl(value) {
30
- if (
31
- typeof value !== 'string' ||
32
- value.length === 0 ||
33
- Buffer.byteLength(value, 'utf8') > MAX_LLM_URL_BYTES ||
34
- [...value].some((character) => {
35
- const codePoint = character.codePointAt(0);
36
- return (
37
- character === '\\' ||
38
- character === '?' ||
39
- character === '#' ||
40
- codePoint < 0x21 ||
41
- codePoint > 0x7e
42
- );
43
- })
44
- ) {
45
- throw new LlmTransportError(
46
- 'LLM_URL_REFUSED',
47
- 'LLM base URL must be a canonical visible-ASCII URL.',
48
- );
49
- }
50
-
51
- let parsed;
52
- try {
53
- parsed = new URL(value);
54
- } catch {
55
- throw new LlmTransportError('LLM_URL_REFUSED', 'LLM base URL must be absolute.');
56
- }
57
- if (
58
- !parsed.hostname ||
59
- parsed.username ||
60
- parsed.password ||
61
- parsed.search ||
62
- parsed.hash ||
63
- parsed.pathname.includes('%') ||
64
- parsed.pathname
65
- .split('/')
66
- .some((segment, index) =>
67
- segment === '.' ||
68
- segment === '..' ||
69
- (index > 0 && segment === '' && parsed.pathname !== '/'))
70
- ) {
71
- throw new LlmTransportError(
72
- 'LLM_URL_REFUSED',
73
- 'LLM base URL contains credentials or an unsupported component.',
74
- );
75
- }
76
- if (
77
- parsed.protocol !== 'https:' &&
78
- !(parsed.protocol === 'http:' && isExactLoopback(parsed.hostname))
79
- ) {
80
- throw new LlmTransportError(
81
- 'LLM_URL_REFUSED',
82
- 'LLM base URL must use HTTPS except for an exact loopback HTTP origin.',
83
- );
84
- }
85
-
86
- const canonical = parsed.pathname === '/' ? parsed.origin : `${parsed.origin}${parsed.pathname}`;
87
- if (value !== canonical || (parsed.pathname !== '/' && parsed.pathname.endsWith('/'))) {
88
- throw new LlmTransportError('LLM_URL_REFUSED', 'LLM base URL is not canonical.');
89
- }
90
- return canonical;
91
- }
92
-
93
- function providerEndpoint(baseUrl, endpoint) {
94
- if (!ALLOWED_ENDPOINTS.has(endpoint)) {
95
- throw new LlmTransportError('LLM_URL_REFUSED', 'LLM provider endpoint is not admitted.');
96
- }
97
- return `${canonicalLlmBaseUrl(baseUrl)}${endpoint}`;
98
- }
99
-
100
- async function refuseInvalidResponse(response) {
101
- try {
102
- await response.body?.cancel();
103
- } catch {
104
- // Response bytes never cross the transport boundary.
105
- }
106
- throw new LlmTransportError('LLM_RESPONSE_INVALID', 'LLM provider returned an invalid response.');
107
- }
108
-
109
- async function readBoundedJson(response) {
110
- const contentType = response.headers.get('content-type') || '';
111
- if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(contentType)) {
112
- return refuseInvalidResponse(response);
113
- }
114
- const contentLength = response.headers.get('content-length');
115
- if (
116
- contentLength !== null &&
117
- (!/^(0|[1-9]\d*)$/.test(contentLength) || Number(contentLength) > MAX_LLM_RESPONSE_BYTES)
118
- ) {
119
- return refuseInvalidResponse(response);
120
- }
121
- if (!response.body) {
122
- return refuseInvalidResponse(response);
123
- }
124
-
125
- const reader = response.body.getReader();
126
- const chunks = [];
127
- let total = 0;
128
- try {
129
- while (true) {
130
- const { done, value } = await reader.read();
131
- if (done) break;
132
- total += value.byteLength;
133
- if (total > MAX_LLM_RESPONSE_BYTES) {
134
- await reader.cancel();
135
- throw new LlmTransportError(
136
- 'LLM_RESPONSE_INVALID',
137
- 'LLM provider returned an invalid response.',
138
- );
139
- }
140
- chunks.push(Buffer.from(value));
141
- }
142
- const text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, total));
143
- const payload = JSON.parse(text);
144
- if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
145
- throw new Error('not an object');
146
- }
147
- return payload;
148
- } catch (error) {
149
- if (error instanceof LlmTransportError) throw error;
150
- throw new LlmTransportError('LLM_RESPONSE_INVALID', 'LLM provider returned an invalid response.');
151
- } finally {
152
- reader.releaseLock();
153
- }
154
- }
155
-
156
- async function postLlmJson({ baseUrl, endpoint, headers, body, timeoutMs = 120000 }) {
157
- const url = providerEndpoint(baseUrl, endpoint);
158
- let response;
159
- try {
160
- response = await fetch(url, {
161
- method: 'POST',
162
- headers,
163
- body: JSON.stringify(body),
164
- redirect: 'error',
165
- signal: AbortSignal.timeout(timeoutMs),
166
- });
167
- } catch {
168
- throw new LlmTransportError(
169
- 'LLM_TRANSPORT_FAILED',
170
- 'LLM provider request failed [LLM_TRANSPORT_FAILED].',
171
- );
172
- }
173
-
174
- if (!response.ok) {
175
- try {
176
- await response.body?.cancel();
177
- } catch {
178
- // Nothing from the provider response crosses the error boundary.
179
- }
180
- throw new LlmTransportError(
181
- 'LLM_HTTP_ERROR',
182
- `LLM provider rejected the request [LLM_HTTP_ERROR] (HTTP ${response.status}).`,
183
- response.status,
184
- );
185
- }
186
- return readBoundedJson(response);
187
- }
188
-
189
- module.exports = {
190
- LlmTransportError,
191
- MAX_LLM_RESPONSE_BYTES,
192
- canonicalLlmBaseUrl,
193
- isExactLoopback,
194
- postLlmJson,
195
- providerEndpoint,
196
- };