octo-dev 0.7.2 → 0.8.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/package.json +5 -2
- package/src/cli/config.command.ts +49 -2
- package/src/cli/index.ts +9 -1
- package/src/infra/compose-aggregator.ts +34 -5
- package/src/infra/compose-smart-merger.ts +27 -14
- package/src/infra/infra-manager.ts +6 -3
- package/src/shared/config.ts +50 -0
- package/src/shared/llm.ts +57 -46
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "octo-dev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Build orchestration, semantic versioning, and local infrastructure management for repository workspaces",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,9 +43,12 @@
|
|
|
43
43
|
"type-check": "tsc --noEmit"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
+
"ai": "^6.0.194",
|
|
47
|
+
"@ai-sdk/openai": "^3.0.67",
|
|
48
|
+
"@ai-sdk/google": "^3.0.80",
|
|
49
|
+
"@ai-sdk/anthropic": "^3.0.81",
|
|
46
50
|
"commander": "^13.1.0",
|
|
47
51
|
"execa": "^9.6.1",
|
|
48
|
-
"@huggingface/transformers": "^4.2.0",
|
|
49
52
|
"pino": "^10.3.1",
|
|
50
53
|
"pino-pretty": "^13.1.3",
|
|
51
54
|
"semver": "^7.7.2",
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import { run } from '../shared/process-runner.js';
|
|
2
2
|
import { logger } from '../shared/logger.js';
|
|
3
|
+
import { saveConfig, getConfigPath, type LlmConfig } from '../shared/config.js';
|
|
4
|
+
import { ask, askSecret } from '../shared/prompt.js';
|
|
5
|
+
|
|
6
|
+
const PROVIDERS: Record<string, { baseUrl: string; defaultModel: string }> = {
|
|
7
|
+
openai: { baseUrl: 'https://openai.com/v1', defaultModel: 'gpt-4o-mini' },
|
|
8
|
+
gemini: { baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', defaultModel: 'gemini-2.0-flash' },
|
|
9
|
+
groq: { baseUrl: 'https://api.groq.com/openai/v1', defaultModel: 'llama-3.3-70b-versatile' },
|
|
10
|
+
anthropic: { baseUrl: 'https://api.anthropic.com/v1', defaultModel: 'claude-sonnet-4-20250514' },
|
|
11
|
+
custom: { baseUrl: '', defaultModel: '' },
|
|
12
|
+
};
|
|
3
13
|
|
|
4
14
|
/**
|
|
5
|
-
* octo config git-cache
|
|
15
|
+
* octo config git-cache [value]
|
|
16
|
+
*
|
|
17
|
+
* Sets git credential.helper locally.
|
|
6
18
|
*
|
|
7
|
-
*
|
|
19
|
+
* @param value - The credential helper value (default: "cache").
|
|
8
20
|
*/
|
|
9
21
|
export async function configGitCacheCommand(value?: string): Promise<void> {
|
|
10
22
|
const helper = value || 'cache';
|
|
@@ -15,3 +27,38 @@ export async function configGitCacheCommand(value?: string): Promise<void> {
|
|
|
15
27
|
}
|
|
16
28
|
logger.info(`Git credential.helper set to "${helper}".`);
|
|
17
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* octo config llm
|
|
33
|
+
*
|
|
34
|
+
* Configures the LLM provider interactively.
|
|
35
|
+
* Stores api key and provider settings in ~/.octo-config.json.
|
|
36
|
+
*/
|
|
37
|
+
export async function configLlmCommand(): Promise<void> {
|
|
38
|
+
const providerNames = Object.keys(PROVIDERS);
|
|
39
|
+
logger.info(`Available providers: ${providerNames.join(', ')}`);
|
|
40
|
+
|
|
41
|
+
const provider = await ask('Provider: ');
|
|
42
|
+
if (!providerNames.includes(provider)) {
|
|
43
|
+
logger.error(`Unknown provider "${provider}". Available: ${providerNames.join(', ')}`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const preset = PROVIDERS[provider];
|
|
48
|
+
const baseUrl = provider === 'custom'
|
|
49
|
+
? await ask('Base URL (OpenAI-compatible): ')
|
|
50
|
+
: preset.baseUrl;
|
|
51
|
+
|
|
52
|
+
const model = await ask(`Model [${preset.defaultModel}]: `) || preset.defaultModel;
|
|
53
|
+
const apiKey = await askSecret('API key: ');
|
|
54
|
+
|
|
55
|
+
if (!apiKey) {
|
|
56
|
+
logger.error('API key is required.');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const llm: LlmConfig = { provider, baseUrl, model, apiKey };
|
|
61
|
+
saveConfig({ llm });
|
|
62
|
+
|
|
63
|
+
logger.info(`LLM configured (${provider}/${model}). Saved to ${getConfigPath()}`);
|
|
64
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ const program = new Command();
|
|
|
11
11
|
program
|
|
12
12
|
.name('octo')
|
|
13
13
|
.description('Build orchestration, semantic versioning, and local infrastructure management for repository workspaces')
|
|
14
|
-
.version('0.
|
|
14
|
+
.version('0.8.1');
|
|
15
15
|
|
|
16
16
|
program
|
|
17
17
|
.command('init')
|
|
@@ -106,4 +106,12 @@ config
|
|
|
106
106
|
await configGitCacheCommand(value);
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
config
|
|
110
|
+
.command('llm')
|
|
111
|
+
.description('Configure LLM provider (openai, gemini, groq, anthropic, custom)')
|
|
112
|
+
.action(async () => {
|
|
113
|
+
const { configLlmCommand } = await import('./config.command.js');
|
|
114
|
+
await configLlmCommand();
|
|
115
|
+
});
|
|
116
|
+
|
|
109
117
|
program.parse();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, dirname, relative } from 'node:path';
|
|
3
3
|
import { parse } from 'yaml';
|
|
4
4
|
|
|
5
5
|
/** A discovered docker-compose.yml from a service directory */
|
|
@@ -39,7 +39,33 @@ export interface MergeResult {
|
|
|
39
39
|
|
|
40
40
|
export interface ComposeAggregator {
|
|
41
41
|
discover(servicePaths: string[]): DiscoveredCompose[];
|
|
42
|
-
merge(composes: DiscoveredCompose[]): MergeResult;
|
|
42
|
+
merge(composes: DiscoveredCompose[], rootDir: string): MergeResult;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Rewrites the `build` field of a service definition to be relative to rootDir.
|
|
47
|
+
* Handles both string format (`build: .`) and object format (`build: { context: ./src }`).
|
|
48
|
+
*
|
|
49
|
+
* @param def - The service definition object.
|
|
50
|
+
* @param composeDir - The directory containing the original compose file.
|
|
51
|
+
* @param rootDir - The workspace root where the merged compose will be written.
|
|
52
|
+
* @returns The service definition with corrected build paths.
|
|
53
|
+
*/
|
|
54
|
+
function rewriteBuildPath(def: any, composeDir: string, rootDir: string): any {
|
|
55
|
+
if (!def?.build) return def;
|
|
56
|
+
|
|
57
|
+
const rewritten = { ...def };
|
|
58
|
+
|
|
59
|
+
if (typeof rewritten.build === 'string') {
|
|
60
|
+
const absolutePath = join(composeDir, rewritten.build);
|
|
61
|
+
rewritten.build = `./${relative(rootDir, absolutePath)}`;
|
|
62
|
+
} else if (typeof rewritten.build === 'object') {
|
|
63
|
+
const context = rewritten.build.context ?? '.';
|
|
64
|
+
const absolutePath = join(composeDir, context);
|
|
65
|
+
rewritten.build = { ...rewritten.build, context: `./${relative(rootDir, absolutePath)}` };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return rewritten;
|
|
43
69
|
}
|
|
44
70
|
|
|
45
71
|
/** Extract host ports from a compose service ports definition */
|
|
@@ -71,11 +97,10 @@ export function createComposeAggregator(): ComposeAggregator {
|
|
|
71
97
|
return results;
|
|
72
98
|
},
|
|
73
99
|
|
|
74
|
-
merge(composes: DiscoveredCompose[]): MergeResult {
|
|
100
|
+
merge(composes: DiscoveredCompose[], rootDir: string): MergeResult {
|
|
75
101
|
const merged: MergedCompose = { services: {}, networks: {}, volumes: {} };
|
|
76
102
|
const conflicts: ComposeConflict[] = [];
|
|
77
103
|
|
|
78
|
-
// Track origins for conflict detection
|
|
79
104
|
const serviceOrigins = new Map<string, string[]>();
|
|
80
105
|
const portOrigins = new Map<string, string[]>();
|
|
81
106
|
|
|
@@ -83,12 +108,16 @@ export function createComposeAggregator(): ComposeAggregator {
|
|
|
83
108
|
const { content, path: sourcePath } = compose;
|
|
84
109
|
if (!content) continue;
|
|
85
110
|
|
|
111
|
+
const composeDir = dirname(sourcePath);
|
|
112
|
+
|
|
86
113
|
// Merge services
|
|
87
114
|
if (content.services) {
|
|
88
|
-
for (const [name,
|
|
115
|
+
for (const [name, rawDef] of Object.entries(content.services)) {
|
|
89
116
|
if (!serviceOrigins.has(name)) serviceOrigins.set(name, []);
|
|
90
117
|
serviceOrigins.get(name)!.push(sourcePath);
|
|
91
118
|
|
|
119
|
+
const def = rewriteBuildPath(rawDef, composeDir, rootDir);
|
|
120
|
+
|
|
92
121
|
// Detect port conflicts
|
|
93
122
|
if (def?.ports && Array.isArray(def.ports)) {
|
|
94
123
|
for (const hostPort of extractHostPorts(def.ports)) {
|
|
@@ -11,46 +11,59 @@ const MergedComposeSchema = z.object({
|
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
export interface ComposeSmartMerger {
|
|
14
|
-
deduplicate(composes: DiscoveredCompose[]): Promise<MergedCompose>;
|
|
14
|
+
deduplicate(composes: DiscoveredCompose[], rootDir: string): Promise<MergedCompose>;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Builds the LLM prompt for optimized compose merging.
|
|
19
|
+
* Instructs the model to unify redundant infrastructure (databases, caches, brokers)
|
|
20
|
+
* into shared instances while preserving application-specific services.
|
|
21
|
+
*
|
|
22
|
+
* @param composes - Array of discovered compose files to merge.
|
|
23
|
+
* @returns The formatted prompt string.
|
|
24
|
+
*/
|
|
18
25
|
function buildPrompt(composes: DiscoveredCompose[]): string {
|
|
19
26
|
const composesText = composes
|
|
20
27
|
.map((c) => `--- ${c.serviceName} (${c.path}) ---\n${JSON.stringify(c.content, null, 2)}`)
|
|
21
28
|
.join('\n\n');
|
|
22
29
|
|
|
23
|
-
return `You are a Docker Compose expert. Given multiple docker-compose files from
|
|
30
|
+
return `You are a Docker Compose optimization expert. Given multiple docker-compose files from a workspace of microservices, produce the most optimized unified compose possible.
|
|
24
31
|
|
|
25
|
-
|
|
26
|
-
1.
|
|
27
|
-
2.
|
|
28
|
-
3.
|
|
29
|
-
4.
|
|
30
|
-
5.
|
|
32
|
+
Optimization goals (in priority order):
|
|
33
|
+
1. UNIFY shared infrastructure — multiple PostgreSQL, Redis, MongoDB, Elasticsearch, RabbitMQ, NATS, or similar containers MUST be consolidated into a single shared instance. Create separate databases/schemas via environment variables or init scripts, not separate containers.
|
|
34
|
+
2. UNIFY shared volumes — if multiple services mount the same type of volume (e.g. pg-data), consolidate into one.
|
|
35
|
+
3. UNIFY networks — use a single shared network unless isolation is explicitly required for security.
|
|
36
|
+
4. PRESERVE application services — each microservice container remains separate (they are distinct apps).
|
|
37
|
+
5. MERGE environment variables — when unifying databases, collect all required databases/users into the shared instance config.
|
|
38
|
+
6. AVOID port conflicts — if two services expose the same host port, remap one to an available port.
|
|
39
|
+
7. USE latest image versions when duplicates exist with different tags.
|
|
40
|
+
8. ADD healthchecks to infrastructure services (postgres, redis, etc.) if not already present.
|
|
41
|
+
9. ADD depends_on with condition: service_healthy for app services that need infrastructure.
|
|
42
|
+
|
|
43
|
+
Example: If service-a has postgres:16 on port 5432 and service-b has postgres:15 on port 5433, produce ONE postgres:16 container with both databases created via POSTGRES_MULTIPLE_DATABASES env or an init script volume.
|
|
31
44
|
|
|
32
45
|
Input compose files:
|
|
33
46
|
${composesText}
|
|
34
47
|
|
|
35
48
|
Return ONLY valid JSON with this exact structure:
|
|
36
|
-
{"services": {...}, "networks": {...}, "volumes": {...}}
|
|
49
|
+
{"services": {...}, "networks": {...}, "volumes": {...}}`
|
|
37
50
|
}
|
|
38
51
|
|
|
39
52
|
export function createComposeSmartMerger(): ComposeSmartMerger {
|
|
40
53
|
const aggregator = createComposeAggregator();
|
|
41
54
|
|
|
42
55
|
return {
|
|
43
|
-
async deduplicate(composes: DiscoveredCompose[]): Promise<MergedCompose> {
|
|
56
|
+
async deduplicate(composes: DiscoveredCompose[], rootDir: string): Promise<MergedCompose> {
|
|
44
57
|
if (composes.length === 0) {
|
|
45
58
|
return { services: {}, networks: {}, volumes: {} };
|
|
46
59
|
}
|
|
47
60
|
|
|
48
61
|
// Try LLM-based merge
|
|
49
|
-
if (
|
|
62
|
+
if (isAvailable()) {
|
|
50
63
|
try {
|
|
51
64
|
logger.info('Using local AI for smart compose merge...');
|
|
52
65
|
const prompt = buildPrompt(composes);
|
|
53
|
-
const parsed = await generateJSON(prompt);
|
|
66
|
+
const parsed = await generateJSON(prompt, 4096);
|
|
54
67
|
|
|
55
68
|
if (parsed) {
|
|
56
69
|
const validated = MergedComposeSchema.safeParse(parsed);
|
|
@@ -69,7 +82,7 @@ export function createComposeSmartMerger(): ComposeSmartMerger {
|
|
|
69
82
|
}
|
|
70
83
|
|
|
71
84
|
// Fallback: deterministic merge
|
|
72
|
-
const { merged } = aggregator.merge(composes);
|
|
85
|
+
const { merged } = aggregator.merge(composes, rootDir);
|
|
73
86
|
return merged;
|
|
74
87
|
},
|
|
75
88
|
};
|
|
@@ -164,7 +164,7 @@ async function resolveComposePath(
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
logger.info(`Found ${discovered.length} compose files with changes — merging into unified docker-compose.yml`);
|
|
167
|
-
const merged = await smartMerger.deduplicate(discovered);
|
|
167
|
+
const merged = await smartMerger.deduplicate(discovered, rootDir);
|
|
168
168
|
const outputPath = await writeMergedCompose(merged, rootDir);
|
|
169
169
|
await writeChecksum(rootDir, currentChecksum);
|
|
170
170
|
return outputPath;
|
|
@@ -205,11 +205,14 @@ export function createInfraManager(servicePaths: string[], rootDir: string): Inf
|
|
|
205
205
|
const composePath = await resolveComposePath(discovered, rootDir, smartMerger);
|
|
206
206
|
|
|
207
207
|
logger.info('Starting containers with docker compose...');
|
|
208
|
-
const result = await run('docker', ['compose', '-f', composePath, 'up', '-d']
|
|
208
|
+
const result = await run('docker', ['compose', '-f', composePath, 'up', '-d', '--build'], {
|
|
209
|
+
timeout: 600_000,
|
|
210
|
+
interactive: true,
|
|
211
|
+
});
|
|
209
212
|
if (result.exitCode !== 0) {
|
|
210
213
|
return {
|
|
211
214
|
success: false,
|
|
212
|
-
message: `Failed to start containers. docker compose exited with code ${result.exitCode}
|
|
215
|
+
message: `Failed to start containers. docker compose exited with code ${result.exitCode}.`,
|
|
213
216
|
};
|
|
214
217
|
}
|
|
215
218
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
|
|
5
|
+
const CONFIG_FILE = join(homedir(), '.octo-config.json');
|
|
6
|
+
|
|
7
|
+
export interface LlmConfig {
|
|
8
|
+
provider: string;
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
model: string;
|
|
11
|
+
apiKey: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface OctoConfig {
|
|
15
|
+
llm?: LlmConfig;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Loads the octo configuration from ~/.octo-config.json.
|
|
20
|
+
* Returns an empty config object if the file doesn't exist or is invalid.
|
|
21
|
+
*
|
|
22
|
+
* @returns The parsed configuration.
|
|
23
|
+
*/
|
|
24
|
+
export function loadConfig(): OctoConfig {
|
|
25
|
+
if (!existsSync(CONFIG_FILE)) return {};
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')) as OctoConfig;
|
|
28
|
+
} catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Saves the octo configuration to ~/.octo-config.json.
|
|
35
|
+
* Merges with existing config (does not overwrite unrelated keys).
|
|
36
|
+
*
|
|
37
|
+
* @param partial - Partial config to merge and persist.
|
|
38
|
+
*/
|
|
39
|
+
export function saveConfig(partial: Partial<OctoConfig>): void {
|
|
40
|
+
const existing = loadConfig();
|
|
41
|
+
const merged = { ...existing, ...partial };
|
|
42
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2), 'utf-8');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Returns the path to the config file for display purposes.
|
|
47
|
+
*/
|
|
48
|
+
export function getConfigPath(): string {
|
|
49
|
+
return CONFIG_FILE;
|
|
50
|
+
}
|
package/src/shared/llm.ts
CHANGED
|
@@ -1,69 +1,78 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { generateText } from 'ai';
|
|
2
|
+
import { createOpenAI } from '@ai-sdk/openai';
|
|
3
|
+
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
|
4
|
+
import { createAnthropic } from '@ai-sdk/anthropic';
|
|
2
5
|
import { logger } from './logger.js';
|
|
3
|
-
|
|
4
|
-
const MODEL_ID = 'onnx-community/Qwen2.5-0.5B-Instruct';
|
|
5
|
-
|
|
6
|
-
let generator: TextGenerationPipeline | null = null;
|
|
7
|
-
let initFailed = false;
|
|
6
|
+
import { loadConfig, type LlmConfig } from './config.js';
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Resolves the AI SDK language model from the stored config.
|
|
10
|
+
* Supports openai, gemini, groq, anthropic, and any OpenAI-compatible custom endpoint.
|
|
11
|
+
*
|
|
12
|
+
* @param config - The LLM configuration with provider, baseUrl, model, and apiKey.
|
|
13
|
+
* @returns An AI SDK language model instance.
|
|
12
14
|
*/
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
15
|
+
function resolveModel(config: LlmConfig) {
|
|
16
|
+
switch (config.provider) {
|
|
17
|
+
case 'google':
|
|
18
|
+
case 'gemini': {
|
|
19
|
+
const google = createGoogleGenerativeAI({ apiKey: config.apiKey });
|
|
20
|
+
return google(config.model);
|
|
21
|
+
}
|
|
22
|
+
case 'anthropic': {
|
|
23
|
+
const anthropic = createAnthropic({ apiKey: config.apiKey });
|
|
24
|
+
return anthropic(config.model);
|
|
25
|
+
}
|
|
26
|
+
case 'openai':
|
|
27
|
+
case 'groq':
|
|
28
|
+
case 'custom':
|
|
29
|
+
default: {
|
|
30
|
+
const openai = createOpenAI({ apiKey: config.apiKey, baseURL: config.baseUrl });
|
|
31
|
+
return openai(config.model);
|
|
32
|
+
}
|
|
28
33
|
}
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
/**
|
|
32
|
-
* Generates text from a prompt using the
|
|
33
|
-
* Returns null if
|
|
37
|
+
* Generates text from a prompt using the configured LLM provider.
|
|
38
|
+
* Returns null if no LLM is configured or the request fails.
|
|
39
|
+
*
|
|
40
|
+
* @param prompt - The user prompt to send.
|
|
41
|
+
* @param maxTokens - Maximum tokens in the response.
|
|
42
|
+
* @returns The generated text, or null on failure.
|
|
34
43
|
*/
|
|
35
44
|
export async function generate(prompt: string, maxTokens = 512): Promise<string | null> {
|
|
36
|
-
const
|
|
37
|
-
if (!
|
|
45
|
+
const config = loadConfig();
|
|
46
|
+
if (!config.llm?.apiKey) return null;
|
|
38
47
|
|
|
39
48
|
try {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
const model = resolveModel(config.llm);
|
|
50
|
+
const { text } = await generateText({
|
|
51
|
+
model,
|
|
52
|
+
prompt,
|
|
53
|
+
maxTokens,
|
|
54
|
+
temperature: 0,
|
|
46
55
|
});
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
return typeof output === 'string' ? output : null;
|
|
53
|
-
} catch {
|
|
56
|
+
return text?.trim() || null;
|
|
57
|
+
} catch (err) {
|
|
58
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
59
|
+
logger.warn(`LLM request failed: ${msg}. Using deterministic fallback.`);
|
|
54
60
|
return null;
|
|
55
61
|
}
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
/**
|
|
59
65
|
* Generates structured JSON output from a prompt.
|
|
60
|
-
* Returns null if parsing fails or
|
|
66
|
+
* Returns null if parsing fails or LLM is unavailable.
|
|
67
|
+
*
|
|
68
|
+
* @param prompt - The prompt requesting JSON output.
|
|
69
|
+
* @param maxTokens - Maximum tokens in the response.
|
|
70
|
+
* @returns Parsed JSON object, or null on failure.
|
|
61
71
|
*/
|
|
62
72
|
export async function generateJSON<T = unknown>(prompt: string, maxTokens = 1024): Promise<T | null> {
|
|
63
73
|
const text = await generate(prompt, maxTokens);
|
|
64
74
|
if (!text) return null;
|
|
65
75
|
|
|
66
|
-
// Extract JSON from the response (model may wrap in markdown code blocks)
|
|
67
76
|
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
68
77
|
if (!jsonMatch) return null;
|
|
69
78
|
|
|
@@ -75,9 +84,11 @@ export async function generateJSON<T = unknown>(prompt: string, maxTokens = 1024
|
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
/**
|
|
78
|
-
* Checks if
|
|
87
|
+
* Checks if an LLM provider is configured.
|
|
88
|
+
*
|
|
89
|
+
* @returns true if LLM config exists with a valid apiKey.
|
|
79
90
|
*/
|
|
80
|
-
export
|
|
81
|
-
const
|
|
82
|
-
return
|
|
91
|
+
export function isAvailable(): boolean {
|
|
92
|
+
const config = loadConfig();
|
|
93
|
+
return !!config.llm?.apiKey;
|
|
83
94
|
}
|