@ulam/halohalo 0.2.0 → 0.3.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/README.md +33 -14
- package/agenticAiService.js +1 -1
- package/aiService.js +1 -1
- package/createProviderConfig.js +15 -10
- package/fetch.js +22 -1
- package/models.js +13 -3
- package/package.json +1 -1
- package/providers.js +5 -3
- package/useProviderConfig.js +12 -7
package/README.md
CHANGED
|
@@ -1,20 +1,41 @@
|
|
|
1
1
|
# @ulam/halohalo
|
|
2
2
|
|
|
3
|
-
AI service adapters, model configuration, and provider abstraction. Vanilla core with
|
|
3
|
+
AI service adapters, model configuration, and provider abstraction. Vanilla core with React, Vue, and Angular adapters.
|
|
4
4
|
|
|
5
5
|
Named for halo-halo, the Filipino shaved ice dessert: a mix of many things that somehow works together.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Purpose & Scope
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
**What halohalo does:**
|
|
10
|
+
|
|
11
|
+
- Provider abstraction for Anthropic, OpenAI, and Google
|
|
12
|
+
- API key management (localStorage-backed, never sent to server)
|
|
13
|
+
- Model selection and configuration per provider
|
|
14
|
+
- Completion calls with consistent interface across providers
|
|
15
|
+
- Tool calling and agentic mode for complex operations
|
|
16
|
+
- Framework-agnostic vanilla core with framework adapters
|
|
17
|
+
- Zero build-time provider detection or configuration
|
|
18
|
+
|
|
19
|
+
**What halohalo doesn't do:**
|
|
20
|
+
|
|
21
|
+
- Message history or conversation management (bring your own state)
|
|
22
|
+
- Streaming response handling (returns complete results)
|
|
23
|
+
- Rate limiting or retry logic (use middleware patterns for these)
|
|
24
|
+
- Token counting or pricing calculation (external concerns)
|
|
25
|
+
- API key validation or rotation (user responsibility)
|
|
26
|
+
- Multi-user authentication or access control
|
|
27
|
+
|
|
28
|
+
**Who should use halohalo:**
|
|
29
|
+
|
|
30
|
+
- Apps that need multiple AI provider support with runtime switching
|
|
31
|
+
- Projects storing API keys client-side (browser-only, no backend)
|
|
32
|
+
- Vanilla JavaScript, React, Vue, or Angular apps using AI features
|
|
33
|
+
- Applications wanting provider-agnostic completion calls
|
|
34
|
+
- Teams integrating AI features without external API servers
|
|
35
|
+
|
|
36
|
+
## The ulam Framework
|
|
37
|
+
|
|
38
|
+
Halohalo is one of six independent packages in the ulam framework. See [docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md) for the complete framework structure and dependency graph.
|
|
18
39
|
|
|
19
40
|
## Install
|
|
20
41
|
|
|
@@ -188,6 +209,4 @@ export class SettingsComponent {
|
|
|
188
209
|
| `@ulam/halohalo/vue` | `useCompletion`, `useProviderConfig` |
|
|
189
210
|
| `@ulam/halohalo/angular` | `CompletionService`, `ProviderConfigService`, `provideHalohalo` |
|
|
190
211
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
MIT
|
|
212
|
+
See the [root README](../../README.md) for a complete framework support overview across all ulam packages.
|
package/agenticAiService.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { callAnthropicWithTools } from './fetch.js'
|
|
2
2
|
import { makeSearchTool } from './search.js'
|
|
3
|
-
import { getAdapter } from '@ulam/sawsawan'
|
|
4
3
|
import { AI_AGENTIC_MAX_TOKENS, AGENTIC_MAX_TOOL_TURNS, LS_APIKEY_PREFIX } from './constants.js'
|
|
5
4
|
import { getAiModel } from './prefs.js'
|
|
6
5
|
import { parseAiResponse } from './aiService.js'
|
|
@@ -24,6 +23,7 @@ const CORPUS_SEARCH_FIELDS = [
|
|
|
24
23
|
const CORPUS_PICK = ['id', 'title', 'primarySC', 'severity', 'desc', 'fix']
|
|
25
24
|
|
|
26
25
|
export async function getAgenticRefinement({ finding, descText, fixText, note, corpus }) {
|
|
26
|
+
const { getAdapter } = await import('@ulam/sawsawan')
|
|
27
27
|
const key = await getAdapter().getKey(`${LS_APIKEY_PREFIX}anthropic`)
|
|
28
28
|
|
|
29
29
|
if (!key) throw new Error('Anthropic API key required for agentic mode. Add one in Settings → AI Assist.')
|
package/aiService.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { callProvider } from './fetch.js'
|
|
2
|
-
import { getAdapter } from '@ulam/sawsawan'
|
|
3
2
|
import { AI_MAX_TOKENS, AI_DESC_REGEX, AI_FIX_REGEX, LS_APIKEY_PREFIX } from './constants.js'
|
|
4
3
|
import { getAiProvider, getAiModel } from './prefs.js'
|
|
5
4
|
import { getBuildPrompt } from './init.js'
|
|
@@ -20,6 +19,7 @@ export async function getAiRefinement({ finding, descText, fixText, note }) {
|
|
|
20
19
|
if (!buildPrompt) throw new Error('halohalo: call initHalohalo({ buildPrompt }) before getAiRefinement')
|
|
21
20
|
|
|
22
21
|
const provider = getAiProvider()
|
|
22
|
+
const { getAdapter } = await import('@ulam/sawsawan')
|
|
23
23
|
const key = await getAdapter().getKey(`${LS_APIKEY_PREFIX}${provider}`)
|
|
24
24
|
|
|
25
25
|
if (!key) throw new Error(`No API key found for ${provider}. Add one in Settings.`)
|
package/createProviderConfig.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { DEFAULT_MODELS, DEFAULT_PROVIDERS, DEFAULT_PROVIDER_LABELS } from './providers.js'
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
async function getAdapter() {
|
|
4
|
+
const mod = await import('@ulam/sawsawan')
|
|
5
|
+
return mod.getAdapter()
|
|
6
|
+
}
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* Vanilla provider config store. No React required.
|
|
@@ -8,23 +12,24 @@ import { getAdapter } from '@ulam/sawsawan'
|
|
|
8
12
|
* storageKeys: { provider, modelPrefix, keyPrefix, mode? }
|
|
9
13
|
* providers: optional array of { id, label, defaultModel? }
|
|
10
14
|
*/
|
|
11
|
-
export function createProviderConfig(storageKeys, providers = DEFAULT_PROVIDERS) {
|
|
15
|
+
export async function createProviderConfig(storageKeys, providers = DEFAULT_PROVIDERS) {
|
|
12
16
|
const { provider: providerKey, modelPrefix, keyPrefix, mode: modeKey } = storageKeys
|
|
17
|
+
const adapter = await getAdapter()
|
|
13
18
|
|
|
14
19
|
const providerList = providers.map(p =>
|
|
15
20
|
typeof p === 'string' ? { id: p, label: DEFAULT_PROVIDER_LABELS[p] || p } : p
|
|
16
21
|
)
|
|
17
22
|
|
|
18
|
-
let provider =
|
|
23
|
+
let provider = adapter.readPref(providerKey) || providerList[0]?.id || 'anthropic'
|
|
19
24
|
|
|
20
25
|
let models = Object.fromEntries(
|
|
21
26
|
providerList.map(p => [
|
|
22
27
|
p.id,
|
|
23
|
-
|
|
28
|
+
adapter.readPref(`${modelPrefix}${p.id}`) || p.defaultModel || DEFAULT_MODELS[p.id] || '',
|
|
24
29
|
])
|
|
25
30
|
)
|
|
26
31
|
|
|
27
|
-
let mode = modeKey ?
|
|
32
|
+
let mode = modeKey ? adapter.readPref(modeKey) === 'true' : false
|
|
28
33
|
|
|
29
34
|
const listeners = new Set()
|
|
30
35
|
const notify = () => listeners.forEach(fn => fn())
|
|
@@ -36,30 +41,30 @@ export function createProviderConfig(storageKeys, providers = DEFAULT_PROVIDERS)
|
|
|
36
41
|
get providers() { return providerList },
|
|
37
42
|
|
|
38
43
|
setProvider(id) {
|
|
39
|
-
|
|
44
|
+
adapter.writePref(providerKey, id)
|
|
40
45
|
provider = id
|
|
41
46
|
notify()
|
|
42
47
|
},
|
|
43
48
|
|
|
44
49
|
setModel(providerId, modelId) {
|
|
45
|
-
|
|
50
|
+
adapter.writePref(`${modelPrefix}${providerId}`, modelId)
|
|
46
51
|
models = { ...models, [providerId]: modelId }
|
|
47
52
|
notify()
|
|
48
53
|
},
|
|
49
54
|
|
|
50
55
|
setMode(value) {
|
|
51
56
|
if (!modeKey) return
|
|
52
|
-
|
|
57
|
+
adapter.writePref(modeKey, value ? 'true' : 'false')
|
|
53
58
|
mode = value
|
|
54
59
|
notify()
|
|
55
60
|
},
|
|
56
61
|
|
|
57
62
|
async setKey(providerId, value) {
|
|
58
|
-
await
|
|
63
|
+
await adapter.setKey(`${keyPrefix}${providerId}`, value)
|
|
59
64
|
},
|
|
60
65
|
|
|
61
66
|
async getKey(providerId) {
|
|
62
|
-
return (await
|
|
67
|
+
return (await adapter.getKey(`${keyPrefix}${providerId}`)) || ''
|
|
63
68
|
},
|
|
64
69
|
|
|
65
70
|
getModel(providerId) {
|
package/fetch.js
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import { AiApiError, httpStatusToErrorType, PROVIDER_CONFIGS } from './providers.js'
|
|
2
2
|
|
|
3
|
+
// ─── Provider URL Whitelist ───────────────────────────────────────────────────
|
|
4
|
+
// Prevents SSRF attacks from user-configured provider URLs.
|
|
5
|
+
const ALLOWED_PROVIDER_HOSTS = new Set([
|
|
6
|
+
'api.anthropic.com',
|
|
7
|
+
'api.openai.com',
|
|
8
|
+
'generativelanguage.googleapis.com',
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
function validateProviderUrl(url) {
|
|
12
|
+
try {
|
|
13
|
+
const hostname = new URL(url).hostname
|
|
14
|
+
return ALLOWED_PROVIDER_HOSTS.has(hostname)
|
|
15
|
+
} catch {
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
3
20
|
// ─── callProvider ─────────────────────────────────────────────────────────────
|
|
4
21
|
// Single-turn completion against any configured provider.
|
|
5
22
|
// Returns the response text string.
|
|
@@ -13,7 +30,11 @@ export async function callProvider({ provider, model, key, prompt, maxTokens = 1
|
|
|
13
30
|
url = config.buildUrl(key, model)
|
|
14
31
|
if (!url) throw new AiApiError('api_error', { provider })
|
|
15
32
|
} else {
|
|
16
|
-
url = config.url
|
|
33
|
+
url = config.url.replace('{model}', model)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!validateProviderUrl(url)) {
|
|
37
|
+
throw new AiApiError('api_error', { provider })
|
|
17
38
|
}
|
|
18
39
|
|
|
19
40
|
let res
|
package/models.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import { getAdapter } from '@ulam/sawsawan'
|
|
2
1
|
import { LS_AI_MODEL_PREFIX, LS_APIKEY_PREFIX, DEFAULT_AI_MODELS } from './constants.js'
|
|
3
2
|
|
|
3
|
+
let _adapter = null
|
|
4
|
+
async function getAdapterInstance() {
|
|
5
|
+
if (!_adapter) {
|
|
6
|
+
const mod = await import('@ulam/sawsawan')
|
|
7
|
+
_adapter = mod.getAdapter()
|
|
8
|
+
}
|
|
9
|
+
return _adapter
|
|
10
|
+
}
|
|
11
|
+
|
|
4
12
|
export const PROVIDERS = [
|
|
5
13
|
{ id: 'anthropic', label: 'Anthropic (Claude)', placeholderKey: 'settings.api_placeholder_anthropic' },
|
|
6
14
|
{ id: 'openai', label: 'OpenAI (GPT)', placeholderKey: 'settings.api_placeholder_openai' },
|
|
@@ -26,13 +34,15 @@ export const PROVIDER_MODELS = {
|
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
export function initModels() {
|
|
37
|
+
const adapter = getAdapterInstance()
|
|
29
38
|
return Object.fromEntries(
|
|
30
|
-
PROVIDERS.map(p => [p.id,
|
|
39
|
+
PROVIDERS.map(p => [p.id, adapter.readPref(`${LS_AI_MODEL_PREFIX}${p.id}`) || DEFAULT_AI_MODELS[p.id] || ''])
|
|
31
40
|
)
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
export function initApiKeys() {
|
|
44
|
+
const adapter = getAdapterInstance()
|
|
35
45
|
return Object.fromEntries(
|
|
36
|
-
PROVIDERS.map(p => [p.id, window.electronAPI ? '' :
|
|
46
|
+
PROVIDERS.map(p => [p.id, window.electronAPI ? '' : adapter.readPref(`${LS_APIKEY_PREFIX}${p.id}`) || ''])
|
|
37
47
|
)
|
|
38
48
|
}
|
package/package.json
CHANGED
package/providers.js
CHANGED
|
@@ -60,9 +60,11 @@ export const PROVIDER_CONFIGS = {
|
|
|
60
60
|
},
|
|
61
61
|
|
|
62
62
|
google: {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
url: 'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent',
|
|
64
|
+
buildHeaders: (key) => ({
|
|
65
|
+
'Content-Type': 'application/json',
|
|
66
|
+
'Authorization': `Bearer ${key}`,
|
|
67
|
+
}),
|
|
66
68
|
buildBody: (prompt, _model, maxTokens) => JSON.stringify({
|
|
67
69
|
contents: [{ parts: [{ text: prompt }] }],
|
|
68
70
|
generationConfig: { maxOutputTokens: maxTokens },
|
package/useProviderConfig.js
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
import { useState, useCallback,
|
|
1
|
+
import { useState, useCallback, useSyncExternalStore } from 'react'
|
|
2
2
|
import { createProviderConfig } from './createProviderConfig.js'
|
|
3
3
|
|
|
4
4
|
export function useProviderConfig(storageKeys, providers) {
|
|
5
5
|
const [config] = useState(() => createProviderConfig(storageKeys, providers))
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
|
|
7
|
+
const snapshot = useSyncExternalStore(
|
|
8
|
+
(listen) => config.subscribe(listen),
|
|
9
|
+
() => ({
|
|
10
|
+
provider: config.provider,
|
|
11
|
+
models: config.models,
|
|
12
|
+
mode: config.mode,
|
|
13
|
+
providers: config.providers,
|
|
14
|
+
})
|
|
15
|
+
)
|
|
8
16
|
|
|
9
17
|
return {
|
|
10
|
-
|
|
11
|
-
models: config.models,
|
|
12
|
-
mode: config.mode,
|
|
13
|
-
providers: config.providers,
|
|
18
|
+
...snapshot,
|
|
14
19
|
setProvider: useCallback((id) => config.setProvider(id), [config]),
|
|
15
20
|
setModel: useCallback((pid, mid) => config.setModel(pid, mid), [config]),
|
|
16
21
|
setMode: useCallback((v) => config.setMode(v), [config]),
|