snapkitty
hardware
erlang
File size: 6,435 Bytes
b8a03fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
import type { BobChatMessage, DesktopToolSummary, ModelProviderId } from '../shared.js';
import type { ResolvedModelProvider } from './providers.js';

export interface ModelChatInput {
  readonly prompt: string;
  readonly history: ReadonlyArray<BobChatMessage>;
  readonly provider: ResolvedModelProvider;
  readonly tools: ReadonlyArray<DesktopToolSummary>;
  readonly toolResult?: string;
}

const REQUEST_TIMEOUT_MS = 90_000;

export async function callConfiguredModel(input: ModelChatInput): Promise<string> {
  switch (input.provider.providerId) {
    case 'ollama':
      return callOllama(input);
    case 'anthropic':
      return callAnthropic(input);
    case 'openrouter':
    case 'openai-compatible':
      return callOpenAICompatible(input);
    case 'local':
      throw new Error('Local provider uses deterministic BOB fallback.');
  }
}

export function requiresApiKey(providerId: ModelProviderId): boolean {
  return providerId === 'anthropic' || providerId === 'openrouter';
}

export function buildSystemPrompt(tools: ReadonlyArray<DesktopToolSummary>): string {
  return [
    'You are BOB inside the local Sovereign Desktop IDE.',
    'Be direct and operational. Do not claim you ran a tool unless a tool result is provided.',
    'Never ask the user for secret keys in chat. Keys are managed only through the IDE provider settings.',
    'You may request one tool call when useful. To call a tool, reply with only JSON in this shape:',
    '{"tool_call":{"name":"workspace.search","input":{"pattern":"ShadowOrchestrator","path":"desktop"}}}',
    'Available tools:',
    JSON.stringify(tools, null, 2),
  ].join('\n');
}

function buildMessages(input: ModelChatInput): Array<{ role: 'system' | 'user' | 'assistant'; content: string }> {
  const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [
    { role: 'system', content: buildSystemPrompt(input.tools) },
  ];

  for (const message of input.history.slice(-8)) {
    if (message.role === 'tool') continue;
    messages.push({
      role: message.role === 'bob' ? 'assistant' : 'user',
      content: message.content,
    });
  }

  if (input.toolResult) {
    messages.push({
      role: 'user',
      content: [
        'Tool result returned to the model.',
        input.toolResult,
        'Now answer the original user request using this result.',
      ].join('\n\n'),
    });
  } else {
    messages.push({ role: 'user', content: input.prompt });
  }

  return messages;
}

async function callOllama(input: ModelChatInput): Promise<string> {
  const response = await postJson(`${input.provider.baseUrl}/api/chat`, {
    model: input.provider.model,
    stream: false,
    messages: buildMessages(input).map(message => ({
      role: message.role,
      content: message.content,
    })),
  });

  const content = responseJson(response, ['message', 'content']);
  if (content) return content;
  throw new Error('Ollama response did not include message.content');
}

async function callAnthropic(input: ModelChatInput): Promise<string> {
  if (!input.provider.apiKey) {
    throw new Error('Anthropic API key is not configured.');
  }

  const messages = buildMessages(input);
  const system = messages.find(message => message.role === 'system')?.content ?? buildSystemPrompt(input.tools);
  const conversational = messages.filter(message => message.role !== 'system');
  const response = await postJson(`${input.provider.baseUrl}/v1/messages`, {
    model: input.provider.model,
    max_tokens: 4096,
    temperature: 0.2,
    system,
    messages: conversational.map(message => ({
      role: message.role === 'assistant' ? 'assistant' : 'user',
      content: message.content,
    })),
  }, {
    'x-api-key': input.provider.apiKey,
    'anthropic-version': '2023-06-01',
  });

  const blocks = responseValue(response, ['content']);
  if (Array.isArray(blocks)) {
    const text = blocks
      .map(block => typeof block?.text === 'string' ? block.text : '')
      .filter(Boolean)
      .join('\n');
    if (text) return text;
  }

  throw new Error('Anthropic response did not include text content');
}

async function callOpenAICompatible(input: ModelChatInput): Promise<string> {
  if (requiresApiKey(input.provider.providerId) && !input.provider.apiKey) {
    throw new Error(`${input.provider.label} API key is not configured.`);
  }

  const headers: Record<string, string> = {};
  if (input.provider.apiKey) headers.Authorization = `Bearer ${input.provider.apiKey}`;
  if (input.provider.providerId === 'openrouter') {
    headers['HTTP-Referer'] = 'https://local.shadow.desktop';
    headers['X-Title'] = 'BOB Desktop IDE';
  }

  const response = await postJson(`${input.provider.baseUrl}/chat/completions`, {
    model: input.provider.model,
    temperature: 0.2,
    messages: buildMessages(input),
  }, headers);

  const content = responseJson(response, ['choices', '0', 'message', 'content']);
  if (content) return content;
  throw new Error(`${input.provider.label} response did not include choices[0].message.content`);
}

async function postJson(
  url: string,
  body: unknown,
  headers: Record<string, string> = {},
): Promise<unknown> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);

  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        ...headers,
      },
      body: JSON.stringify(body),
      signal: controller.signal,
    });

    const text = await response.text();
    if (!response.ok) {
      throw new Error(`Model request failed ${response.status}: ${text.slice(0, 600)}`);
    }
    return text ? JSON.parse(text) : {};
  } finally {
    clearTimeout(timer);
  }
}

function responseJson(value: unknown, path: ReadonlyArray<string>): string | undefined {
  const found = responseValue(value, path);
  return typeof found === 'string' ? found : undefined;
}

function responseValue(value: unknown, path: ReadonlyArray<string>): unknown {
  let current: unknown = value;
  for (const segment of path) {
    if (Array.isArray(current) && /^\d+$/.test(segment)) {
      current = current[Number(segment)];
      continue;
    }
    if (typeof current !== 'object' || current === null || !(segment in current)) {
      return undefined;
    }
    current = (current as Record<string, unknown>)[segment];
  }
  return current;
}