opc-agent 4.0.43 → 4.1.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.
@@ -166,12 +166,96 @@ class StudioServer {
166
166
  const industry = url.searchParams.get('industry') || '';
167
167
  const search = url.searchParams.get('q') || '';
168
168
  data = this.getTemplates(industry, search);
169
+ // Merge with real workstation templates
170
+ try {
171
+ const ws = require('agent-workstation');
172
+ const categories = ws.getCategories();
173
+ const wsTemplates = [];
174
+ for (const cat of categories) {
175
+ for (const roleName of cat.roles) {
176
+ const role = ws.getRole(cat.name, roleName);
177
+ if (!role)
178
+ continue;
179
+ let oad = {};
180
+ try {
181
+ if (role.files?.['oad.yaml']) {
182
+ const yaml = require('js-yaml');
183
+ oad = yaml.load(role.files['oad.yaml']) || {};
184
+ }
185
+ }
186
+ catch { }
187
+ const tpl = {
188
+ id: `ws-${cat.name}-${roleName}`,
189
+ name: oad.name || roleName.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
190
+ nameZh: oad.nameZh || '',
191
+ icon: oad.icon || '🤖',
192
+ description: oad.description || '',
193
+ descriptionZh: oad.descriptionZh || '',
194
+ industry: cat.name,
195
+ industryZh: cat.name,
196
+ tags: [cat.name, 'workstation'],
197
+ suggestedModel: 'auto',
198
+ systemPrompt: oad.systemPrompt || role.files?.['brain-seed.md'] || '',
199
+ source: 'workstation',
200
+ ego: oad.ego || null,
201
+ mission: oad.mission || null,
202
+ skills: oad.skills || [],
203
+ };
204
+ if (!search || tpl.name.toLowerCase().includes(search.toLowerCase()) || tpl.nameZh.includes(search)) {
205
+ if (!industry || tpl.industry === industry) {
206
+ wsTemplates.push(tpl);
207
+ }
208
+ }
209
+ }
210
+ }
211
+ data.templates = [...data.templates, ...wsTemplates];
212
+ // Add workstation industries to list
213
+ const existingIds = new Set(data.industries.map((i) => i.id));
214
+ for (const cat of categories) {
215
+ if (!existingIds.has(cat.name)) {
216
+ data.industries.push({ id: cat.name, name: cat.name, nameZh: cat.name });
217
+ }
218
+ }
219
+ }
220
+ catch (wsErr) {
221
+ // workstation not available, use built-in templates only
222
+ }
169
223
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
170
224
  res.end(JSON.stringify(data));
171
225
  return;
172
226
  }
173
227
  if (route.match(/^templates\/[^/]+$/) && req.method === 'GET') {
174
228
  const tplId = route.split('/')[1];
229
+ // Check workstation first
230
+ if (tplId.startsWith('ws-')) {
231
+ const parts = tplId.replace('ws-', '').split('-');
232
+ const catName = parts[0];
233
+ const roleName = parts.slice(1).join('-');
234
+ try {
235
+ const ws = require('agent-workstation');
236
+ const role = ws.getRole(catName, roleName);
237
+ if (role) {
238
+ let oad = {};
239
+ try {
240
+ if (role.files?.['oad.yaml']) {
241
+ const yaml = require('js-yaml');
242
+ oad = yaml.load(role.files['oad.yaml']) || {};
243
+ }
244
+ }
245
+ catch { }
246
+ data = {
247
+ id: tplId, name: oad.name || roleName, source: 'workstation',
248
+ category: catName, role: roleName, files: role.files,
249
+ ego: oad.ego, mission: oad.mission, skills: oad.skills,
250
+ systemPrompt: oad.systemPrompt || role.files?.['brain-seed.md'] || '',
251
+ };
252
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
253
+ res.end(JSON.stringify(data));
254
+ return;
255
+ }
256
+ }
257
+ catch { }
258
+ }
175
259
  data = this.getTemplateById(tplId);
176
260
  res.writeHead(data.error ? 404 : 200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
177
261
  res.end(JSON.stringify(data));
@@ -473,6 +557,64 @@ class StudioServer {
473
557
  res.end(JSON.stringify(data));
474
558
  return;
475
559
  }
560
+ // === Global config API (reads/writes ~/.opc/config.json) ===
561
+ if (route === 'config' && req.method === 'GET') {
562
+ data = loadSettingsConfig();
563
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
564
+ res.end(JSON.stringify(data));
565
+ return;
566
+ }
567
+ if (route === 'config' && req.method === 'PUT') {
568
+ const body = JSON.parse(await this.readBody(req));
569
+ const cfg = loadSettingsConfig();
570
+ Object.assign(cfg, body);
571
+ saveSettingsConfig(cfg);
572
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
573
+ res.end(JSON.stringify({ success: true, config: cfg }));
574
+ return;
575
+ }
576
+ // === Models API (real agentkits integration) ===
577
+ if (route === 'models' && req.method === 'GET') {
578
+ try {
579
+ const ak = await Promise.resolve().then(() => __importStar(require('agentkits')));
580
+ const providers = ak.listLLMProviders();
581
+ data = { providers };
582
+ }
583
+ catch (e) {
584
+ data = { providers: [], error: 'agentkits not available: ' + e.message };
585
+ }
586
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
587
+ res.end(JSON.stringify(data));
588
+ return;
589
+ }
590
+ // === Memory stats API (real deepbrain integration) ===
591
+ if (route === 'memory/stats' && req.method === 'GET') {
592
+ try {
593
+ const { Brain } = require('deepbrain');
594
+ const oad = this.loadOAD();
595
+ const dbPath = oad?.spec?.memory?.longTerm?.database || './data/brain.db';
596
+ const brain = new Brain({ database: dbPath, embedding_provider: 'ollama' });
597
+ await brain.connect();
598
+ const stats = await brain.stats();
599
+ await brain.disconnect();
600
+ data = { connected: true, ...stats };
601
+ }
602
+ catch {
603
+ data = { connected: false, pages: 0, chunks: 0, error: 'DeepBrain not installed or not configured. Install with: npm i deepbrain' };
604
+ }
605
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
606
+ res.end(JSON.stringify(data));
607
+ return;
608
+ }
609
+ if (route.match(/^memory\/[^/]+$/) && req.method === 'GET') {
610
+ const agentId = route.split('/')[1];
611
+ if (agentId !== 'stats' && agentId !== 'list' && agentId !== 'search') {
612
+ data = this.getAgentMemory(agentId);
613
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
614
+ res.end(JSON.stringify(data));
615
+ return;
616
+ }
617
+ }
476
618
  switch (route) {
477
619
  case 'modules':
478
620
  data = await this.getModulesStatus();
@@ -710,8 +852,21 @@ class StudioServer {
710
852
  return { entries, timeline: entries.map((e) => ({ date: e.timestamp, summary: e.summary || e.content?.slice(0, 100) })) };
711
853
  }
712
854
  async handleAgentChat(req, res, agentId) {
713
- const body = JSON.parse(await this.readBody(req));
714
- const { messages = [] } = body;
855
+ let body;
856
+ try {
857
+ body = JSON.parse(await this.readBody(req));
858
+ }
859
+ catch {
860
+ res.writeHead(400, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
861
+ res.end(JSON.stringify({ error: 'Invalid JSON body' }));
862
+ return;
863
+ }
864
+ // Accept both { messages: [...] } and { message: "...", history: [...] }
865
+ let messages = body.messages || [];
866
+ if (body.message) {
867
+ // Frontend sends { message, history }
868
+ messages = [...(body.history || []), { role: 'user', content: body.message }];
869
+ }
715
870
  const agent = this.getAgentById(agentId);
716
871
  if (agent.error) {
717
872
  res.writeHead(404, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
@@ -722,8 +877,8 @@ class StudioServer {
722
877
  agent.messageCount = (agent.messageCount || 0) + 1;
723
878
  agent.lastActive = new Date().toISOString();
724
879
  agent.updated = new Date().toISOString();
725
- const filePath = (0, path_1.join)(this.getAgentsDir(), `${agentId}.json`);
726
- (0, fs_1.writeFileSync)(filePath, JSON.stringify(agent, null, 2));
880
+ const agentFilePath = (0, path_1.join)(this.getAgentsDir(), `${agentId}.json`);
881
+ (0, fs_1.writeFileSync)(agentFilePath, JSON.stringify(agent, null, 2));
727
882
  // SSE streaming response
728
883
  res.writeHead(200, {
729
884
  'Content-Type': 'text/event-stream',
@@ -731,30 +886,31 @@ class StudioServer {
731
886
  'Connection': 'keep-alive',
732
887
  'Access-Control-Allow-Origin': '*',
733
888
  });
734
- const allMsgs = [{ role: 'system', content: agent.systemPrompt }, ...messages];
735
- const lastMsg = allMsgs[allMsgs.length - 1]?.content || '';
736
889
  // Use createProvider directly to call LLM
737
890
  try {
738
891
  const { createProvider } = require('../providers');
739
- // Read OAD config for provider info
892
+ // Determine provider: agent config > OAD yaml > env > auto
740
893
  let providerName = agent.provider || process.env.OPC_LLM_PROVIDER;
741
894
  if (!providerName) {
742
- // Try reading from oad.yaml
743
895
  try {
744
- const oadPath = (0, path_1.join)(this.config.agentDir, 'oad.yaml');
745
- if ((0, fs_1.existsSync)(oadPath)) {
746
- const yaml = require('js-yaml');
747
- const oad = yaml.load((0, fs_1.readFileSync)(oadPath, 'utf-8'));
748
- providerName = oad?.spec?.provider?.default;
896
+ for (const fname of ['oad.yaml', 'agent.yaml']) {
897
+ const oadPath = (0, path_1.join)(this.config.agentDir, fname);
898
+ if ((0, fs_1.existsSync)(oadPath)) {
899
+ const yaml = require('js-yaml');
900
+ const oad = yaml.load((0, fs_1.readFileSync)(oadPath, 'utf-8'));
901
+ providerName = oad?.spec?.provider?.default;
902
+ if (providerName)
903
+ break;
904
+ }
749
905
  }
750
906
  }
751
907
  catch { }
752
908
  }
753
- providerName = providerName || 'openai';
909
+ providerName = providerName || 'auto';
754
910
  const provider = createProvider(providerName, agent.model);
755
911
  let fullText = '';
756
912
  try {
757
- for await (const chunk of provider.chatStream(allMsgs, agent.systemPrompt)) {
913
+ for await (const chunk of provider.chatStream(messages, agent.systemPrompt)) {
758
914
  const sseData = JSON.stringify({
759
915
  choices: [{ delta: { content: chunk }, index: 0 }],
760
916
  });
@@ -764,8 +920,9 @@ class StudioServer {
764
920
  }
765
921
  catch (streamErr) {
766
922
  if (!fullText) {
767
- // No content streamed yet, send error
768
- const errData = JSON.stringify({ error: streamErr.message });
923
+ const errData = JSON.stringify({
924
+ choices: [{ delta: { content: `⚠️ LLM Error: ${streamErr.message}` }, index: 0 }],
925
+ });
769
926
  res.write(`data: ${errData}\n\n`);
770
927
  }
771
928
  }
@@ -773,8 +930,13 @@ class StudioServer {
773
930
  res.end();
774
931
  }
775
932
  catch (err) {
776
- // Fallback: try simulated response
777
- this.sendSimulatedResponse(res, lastMsg, agent);
933
+ // Provider creation failed — send error as SSE so frontend can display it
934
+ const errData = JSON.stringify({
935
+ choices: [{ delta: { content: `⚠️ Provider error: ${err.message}\n\nTip: Install Claude CLI (npm i -g @anthropic-ai/claude-code) or set OPENAI_API_KEY.` }, index: 0 }],
936
+ });
937
+ res.write(`data: ${errData}\n\n`);
938
+ res.write('data: [DONE]\n\n');
939
+ res.end();
778
940
  }
779
941
  }
780
942
  sendSimulatedResponse(res, lastMsg, agent) {
@@ -1317,26 +1317,61 @@
1317
1317
 
1318
1318
  agentChatHistory.push({ role: 'user', content: msg });
1319
1319
 
1320
- // Send to API
1320
+ // Add assistant message placeholder
1321
+ const assistantDiv = document.createElement('div');
1322
+ assistantDiv.className = 'agent-chat-msg assistant';
1323
+ assistantDiv.textContent = '';
1324
+ messagesEl.appendChild(assistantDiv);
1325
+
1326
+ // Send to API and parse SSE stream
1321
1327
  try {
1322
1328
  const res = await fetch(`/api/agents/${selectedAgentId}/chat`, {
1323
1329
  method: 'POST',
1324
1330
  headers: { 'Content-Type': 'application/json' },
1325
- body: JSON.stringify({ message: msg, history: agentChatHistory })
1331
+ body: JSON.stringify({ message: msg, history: agentChatHistory.slice(0, -1) })
1326
1332
  });
1327
- const data = await res.json();
1328
- const reply = data.reply || data.message || data.content || JSON.stringify(data);
1329
- const assistantDiv = document.createElement('div');
1330
- assistantDiv.className = 'agent-chat-msg assistant';
1331
- assistantDiv.textContent = reply;
1332
- messagesEl.appendChild(assistantDiv);
1333
- agentChatHistory.push({ role: 'assistant', content: reply });
1333
+
1334
+ if (!res.ok) {
1335
+ let errMsg = `HTTP ${res.status}`;
1336
+ try { const ej = await res.json(); errMsg = ej.error || errMsg; } catch {}
1337
+ throw new Error(errMsg);
1338
+ }
1339
+
1340
+ const reader = res.body.getReader();
1341
+ const decoder = new TextDecoder();
1342
+ let fullReply = '';
1343
+ let buffer = '';
1344
+
1345
+ while (true) {
1346
+ const { done, value } = await reader.read();
1347
+ if (done) break;
1348
+ buffer += decoder.decode(value, { stream: true });
1349
+ const lines = buffer.split('\n');
1350
+ buffer = lines.pop() || '';
1351
+ for (const line of lines) {
1352
+ const trimmed = line.trim();
1353
+ if (!trimmed || !trimmed.startsWith('data: ')) continue;
1354
+ const data = trimmed.slice(6);
1355
+ if (data === '[DONE]') continue;
1356
+ try {
1357
+ const parsed = JSON.parse(data);
1358
+ const content = parsed.choices?.[0]?.delta?.content;
1359
+ if (content) {
1360
+ fullReply += content;
1361
+ assistantDiv.textContent = fullReply;
1362
+ messagesEl.scrollTop = messagesEl.scrollHeight;
1363
+ }
1364
+ } catch {}
1365
+ }
1366
+ }
1367
+
1368
+ if (!fullReply) {
1369
+ assistantDiv.textContent = '(No response received)';
1370
+ }
1371
+ agentChatHistory.push({ role: 'assistant', content: fullReply });
1334
1372
  } catch(e) {
1335
- const errDiv = document.createElement('div');
1336
- errDiv.className = 'agent-chat-msg assistant';
1337
- errDiv.style.borderColor = 'var(--red)';
1338
- errDiv.textContent = `⚠️ 发送失败: ${e.message}`;
1339
- messagesEl.appendChild(errDiv);
1373
+ assistantDiv.style.borderColor = 'var(--red)';
1374
+ assistantDiv.textContent = `⚠️ 发送失败: ${e.message}`;
1340
1375
  }
1341
1376
  messagesEl.scrollTop = messagesEl.scrollHeight;
1342
1377
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opc-agent",
3
- "version": "4.0.43",
3
+ "version": "4.1.0",
4
4
  "description": "Open Agent Framework — Build, test, and run AI Agents for business workstations",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/cli.ts CHANGED
@@ -771,7 +771,7 @@ program
771
771
  let model: string | undefined;
772
772
  let agentName = 'Agent';
773
773
  let agentVersion = '1.0.0';
774
- let providerName = 'openai';
774
+ let providerName = 'auto';
775
775
  let skillNames: string[] = [];
776
776
 
777
777
  // Try loading SOUL.md and CONTEXT.md for enriched system prompt
@@ -796,7 +796,7 @@ program
796
796
  // Prepend SOUL.md and CONTEXT.md to system prompt
797
797
  systemPrompt = [soulContent, contextContent, systemPrompt].filter(Boolean).join('\n\n');
798
798
 
799
- const provider = createProvider('openai', model);
799
+ const provider = createProvider(providerName, model);
800
800
  const history: { role: 'user' | 'assistant' | 'system'; content: string }[] = [];
801
801
 
802
802
  // Print startup banner
@@ -44,6 +44,24 @@ export class AgentRuntime {
44
44
  private evolveScheduler: any = null;
45
45
 
46
46
  async loadConfig(filePath: string): Promise<OADDocument> {
47
+ const fs = require('fs');
48
+ if (!fs.existsSync(filePath)) {
49
+ // Auto-create a minimal oad.yaml with auto-detect provider
50
+ const yaml = require('js-yaml');
51
+ const defaultOAD = {
52
+ apiVersion: 'opc/v1',
53
+ kind: 'Agent',
54
+ metadata: { name: 'my-agent', version: '1.0.0', description: 'OPC Agent' },
55
+ spec: {
56
+ model: 'auto',
57
+ provider: { default: 'auto' },
58
+ systemPrompt: 'You are a helpful AI assistant.',
59
+ channels: [{ type: 'web', config: { port: 3000 } }],
60
+ },
61
+ };
62
+ fs.writeFileSync(filePath, yaml.dump(defaultOAD, { lineWidth: 120 }));
63
+ this.logger.info('Created default oad.yaml (no config file found)');
64
+ }
47
65
  this.config = loadOAD(filePath);
48
66
  this.logger.info('Config loaded', { name: this.config.metadata.name });
49
67
  return this.config;
@@ -330,7 +330,17 @@ class ClaudeCLIProvider implements LLMProvider {
330
330
  private model: string;
331
331
 
332
332
  constructor(model?: string) {
333
- this.model = model || 'sonnet';
333
+ // Claude CLI uses short model names; don't pass API-style model names
334
+ // Let Claude CLI use its default model unless explicitly set to a known CLI model
335
+ const cliModels = ['sonnet', 'opus', 'haiku', 'claude-sonnet-4-20250514', 'claude-opus-4-20250514'];
336
+ if (model && !cliModels.includes(model)) {
337
+ // Map common patterns
338
+ if (model.includes('opus')) this.model = 'opus';
339
+ else if (model.includes('haiku')) this.model = 'haiku';
340
+ else this.model = ''; // let CLI choose default
341
+ } else {
342
+ this.model = model || '';
343
+ }
334
344
  }
335
345
 
336
346
  async chat(messages: Message[], systemPrompt?: string, options?: ChatOptions): Promise<string> {
@@ -349,7 +359,7 @@ class ClaudeCLIProvider implements LLMProvider {
349
359
  prompt += buildToolPrompt(options.tools);
350
360
  }
351
361
 
352
- const args = ['-p', '--no-project-context'];
362
+ const args = ['-p'];
353
363
  // Write system prompt to temp file to avoid shell escaping issues
354
364
  let tmpFile: string | undefined;
355
365
  if (systemPrompt) {
@@ -410,7 +420,7 @@ class ClaudeCLIProvider implements LLMProvider {
410
420
  }
411
421
 
412
422
  async *chatStream(messages: Message[], systemPrompt?: string): AsyncIterable<string> {
413
- const args = ['-p', '--no-project-context', '--output-format', 'stream-json', '--include-partial-messages'];
423
+ const args = ['-p', '--verbose', '--output-format', 'stream-json'];
414
424
  if (this.model) {
415
425
  args.push('--model', this.model);
416
426
  }
@@ -451,14 +461,24 @@ class ClaudeCLIProvider implements LLMProvider {
451
461
  if (!trimmed) continue;
452
462
  try {
453
463
  const event = JSON.parse(trimmed);
454
- // Handle partial message chunks (content_block_delta style)
455
- if (event.type === 'content' && event.content) {
456
- const newContent = event.content;
457
- if (newContent.length > lastContent.length) {
458
- yield newContent.slice(lastContent.length);
459
- lastContent = newContent;
464
+ // Claude CLI stream-json format:
465
+ // {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}}
466
+ if (event.type === 'assistant' && event.message?.content) {
467
+ for (const block of event.message.content) {
468
+ if (block.type === 'text' && block.text) {
469
+ const newContent = block.text;
470
+ if (newContent.length > lastContent.length) {
471
+ yield newContent.slice(lastContent.length);
472
+ lastContent = newContent;
473
+ }
474
+ }
460
475
  }
461
476
  }
477
+ // Also handle result type for final content
478
+ if (event.type === 'result' && event.result) {
479
+ const remaining = event.result.slice(lastContent.length);
480
+ if (remaining) yield remaining;
481
+ }
462
482
  // Handle assistant message with content array
463
483
  if (event.type === 'assistant' && event.message?.content) {
464
484
  for (const block of event.message.content) {
@@ -513,7 +533,72 @@ class ClaudeCLIProvider implements LLMProvider {
513
533
  }
514
534
  }
515
535
 
516
- export function createProvider(name: string = 'openai', model?: string, baseUrl?: string, apiKey?: string): LLMProvider {
536
+ import { execSync } from 'child_process';
537
+
538
+ function detectClaudeCLI(): boolean {
539
+ try {
540
+ execSync('claude --version', { stdio: 'pipe', timeout: 3000 });
541
+ return true;
542
+ } catch { return false; }
543
+ }
544
+
545
+ function detectOllama(): boolean {
546
+ try {
547
+ // Use node http instead of curl for Windows compatibility
548
+ const { execSync: es } = require('child_process');
549
+ // Quick check: try to connect to Ollama API via node
550
+ const result = es(
551
+ `node -e "const h=require('http');const r=h.get('http://localhost:11434/api/tags',{timeout:2000},s=>{let d='';s.on('data',c=>d+=c);s.on('end',()=>{process.stdout.write(d.includes('models')?'1':'0')})});r.on('error',()=>process.stdout.write('0'));r.on('timeout',()=>{r.destroy();process.stdout.write('0')})"`,
552
+ { stdio: 'pipe', timeout: 5000 }
553
+ );
554
+ return result.toString().trim() === '1';
555
+ } catch { return false; }
556
+ }
557
+
558
+ function detectApiKeys(): { provider: string; key: string; baseUrl?: string } | null {
559
+ if (process.env.ANTHROPIC_API_KEY) return { provider: 'anthropic', key: process.env.ANTHROPIC_API_KEY };
560
+ if (process.env.OPENAI_API_KEY && process.env.OPENAI_API_KEY !== 'your-api-key-here') return { provider: 'openai', key: process.env.OPENAI_API_KEY };
561
+ if (process.env.DEEPSEEK_API_KEY) return { provider: 'deepseek', key: process.env.DEEPSEEK_API_KEY, baseUrl: 'https://api.deepseek.com/v1' };
562
+ if (process.env.DASHSCOPE_API_KEY) return { provider: 'qwen', key: process.env.DASHSCOPE_API_KEY, baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1' };
563
+ if (process.env.GEMINI_API_KEY) return { provider: 'gemini', key: process.env.GEMINI_API_KEY };
564
+ return null;
565
+ }
566
+
567
+ export function autoDetectProvider(): { name: string; model?: string; baseUrl?: string; apiKey?: string } {
568
+ // 1. Claude CLI (zero config, Claude Max/Pro subscription)
569
+ if (detectClaudeCLI()) {
570
+ return { name: 'claude-cli' };
571
+ }
572
+
573
+ // 2. API keys from environment
574
+ const apiKey = detectApiKeys();
575
+ if (apiKey) {
576
+ return { name: apiKey.provider, apiKey: apiKey.key, baseUrl: apiKey.baseUrl };
577
+ }
578
+
579
+ // 3. Ollama (local, free)
580
+ if (detectOllama()) {
581
+ return { name: 'ollama', model: 'qwen2.5:7b' };
582
+ }
583
+
584
+ // 4. Nothing found
585
+ return { name: 'none' };
586
+ }
587
+
588
+ export function createProvider(name: string = 'auto', model?: string, baseUrl?: string, apiKey?: string): LLMProvider {
589
+ // Auto-detect if name is 'auto' or default openai with no real key
590
+ const needsAutoDetect = name === 'auto' || (name === 'openai' && !apiKey && (!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === 'your-api-key-here'));
591
+ if (needsAutoDetect) {
592
+ const detected = autoDetectProvider();
593
+ if (detected.name !== 'none') {
594
+ console.log(`[provider] Auto-detected: ${detected.name}`);
595
+ name = detected.name;
596
+ model = model || detected.model;
597
+ baseUrl = baseUrl || detected.baseUrl;
598
+ apiKey = apiKey || detected.apiKey;
599
+ }
600
+ }
601
+
517
602
  const finalModel = model || process.env.OPC_LLM_MODEL || 'gpt-4o-mini';
518
603
 
519
604
  // Claude CLI mode: use local claude command (Claude Max/Pro subscription)