@ulam/halohalo 0.3.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 CHANGED
@@ -1,20 +1,41 @@
1
1
  # @ulam/halohalo
2
2
 
3
- AI service adapters, model configuration, and provider abstraction. Vanilla core with a React hooks adapter.
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
- ## The ulam framework
7
+ ## Purpose & Scope
8
8
 
9
- ```text
10
- ulam
11
- ├── @ulam/halohalo mixed : AI provider adapters ← you are here
12
- ├── @ulam/taho warm : ARIA live region announcer
13
- ├── @ulam/sili hot : focus management, overlays, routing
14
- ├── @ulam/calamansi sour : i18n, hooks, utilities, logic
15
- ├── @ulam/ube sweet : React UI components, theming
16
- └── @ulam/sawsawan bridge : wires the above together
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
- ## License
192
-
193
- MIT
212
+ See the [root README](../../README.md) for a complete framework support overview across all ulam packages.
@@ -1,76 +1,76 @@
1
- import { callAnthropicWithTools } from './fetch.js'
2
- import { makeSearchTool } from './search.js'
3
- import { getAdapter } from '@ulam/sawsawan'
4
- import { AI_AGENTIC_MAX_TOKENS, AGENTIC_MAX_TOOL_TURNS, LS_APIKEY_PREFIX } from './constants.js'
5
- import { getAiModel } from './prefs.js'
6
- import { parseAiResponse } from './aiService.js'
7
- import { getSystemPrompt } from './init.js'
8
-
9
- export { AiApiError } from './providers.js'
10
-
11
- const FALLBACK_SYSTEM_PROMPT = `You are an AI assistant helping rewrite text entries based on user notes. Search for related entries before rewriting, then produce a revised description and suggested fix.
12
-
13
- Format your final output as exactly two lines:
14
- Description: [rewritten description]
15
- Suggested Fix: [rewritten suggested fix]`
16
-
17
- const CORPUS_SEARCH_FIELDS = [
18
- { name: 'title', weight: 0.32 },
19
- { name: 'keywords', weight: 0.30 },
20
- { name: 'desc', weight: 0.07 },
21
- { name: 'fix', weight: 0.03 },
22
- ]
23
-
24
- const CORPUS_PICK = ['id', 'title', 'primarySC', 'severity', 'desc', 'fix']
25
-
26
- export async function getAgenticRefinement({ finding, descText, fixText, note, corpus }) {
27
- const key = await getAdapter().getKey(`${LS_APIKEY_PREFIX}anthropic`)
28
-
29
- if (!key) throw new Error('Anthropic API key required for agentic mode. Add one in Settings → AI Assist.')
30
-
31
- const model = getAiModel('anthropic')
32
-
33
- const { schema: toolSchema, handler: toolHandler } = makeSearchTool(corpus, {
34
- name: 'search_corpus',
35
- description:
36
- 'Search the accessibility finding corpus for entries related to a natural-language query. ' +
37
- 'Call this before rewriting to find similar findings that demonstrate the expected voice, ' +
38
- 'tone, and technical depth. Returns up to 3 matching entries.',
39
- queryDescription: 'Natural-language search query, e.g. "keyboard focus visible" or "color contrast low vision".',
40
- fields: CORPUS_SEARCH_FIELDS,
41
- pick: CORPUS_PICK,
42
- limit: 3,
43
- })
44
-
45
- const userPrompt = `Refine this accessibility finding based on the auditor's note.
46
-
47
- Title: ${finding.title}
48
- WCAG SC: ${finding.primarySC}
49
- Severity: ${finding.severity}
50
- Platform: ${finding.platform}
51
-
52
- Current description:
53
- ${descText}
54
-
55
- Current suggested fix:
56
- ${fixText}
57
-
58
- Auditor's note: "${note}"
59
-
60
- Search the corpus for related findings, then rewrite the description and suggested fix to reflect the refinement.`
61
-
62
- const messages = [{ role: 'user', content: userPrompt }]
63
-
64
- const text = await callAnthropicWithTools({
65
- key,
66
- model,
67
- system: getSystemPrompt() || FALLBACK_SYSTEM_PROMPT,
68
- tools: [toolSchema],
69
- messages,
70
- maxTokens: AI_AGENTIC_MAX_TOKENS,
71
- maxTurns: AGENTIC_MAX_TOOL_TURNS,
72
- onToolCall: toolHandler,
73
- })
74
-
75
- return parseAiResponse(text)
76
- }
1
+ import { callAnthropicWithTools } from './fetch.js'
2
+ import { makeSearchTool } from './search.js'
3
+ import { AI_AGENTIC_MAX_TOKENS, AGENTIC_MAX_TOOL_TURNS, LS_APIKEY_PREFIX } from './constants.js'
4
+ import { getAiModel } from './prefs.js'
5
+ import { parseAiResponse } from './aiService.js'
6
+ import { getSystemPrompt } from './init.js'
7
+
8
+ export { AiApiError } from './providers.js'
9
+
10
+ const FALLBACK_SYSTEM_PROMPT = `You are an AI assistant helping rewrite text entries based on user notes. Search for related entries before rewriting, then produce a revised description and suggested fix.
11
+
12
+ Format your final output as exactly two lines:
13
+ Description: [rewritten description]
14
+ Suggested Fix: [rewritten suggested fix]`
15
+
16
+ const CORPUS_SEARCH_FIELDS = [
17
+ { name: 'title', weight: 0.32 },
18
+ { name: 'keywords', weight: 0.30 },
19
+ { name: 'desc', weight: 0.07 },
20
+ { name: 'fix', weight: 0.03 },
21
+ ]
22
+
23
+ const CORPUS_PICK = ['id', 'title', 'primarySC', 'severity', 'desc', 'fix']
24
+
25
+ export async function getAgenticRefinement({ finding, descText, fixText, note, corpus }) {
26
+ const { getAdapter } = await import('@ulam/sawsawan')
27
+ const key = await getAdapter().getKey(`${LS_APIKEY_PREFIX}anthropic`)
28
+
29
+ if (!key) throw new Error('Anthropic API key required for agentic mode. Add one in Settings → AI Assist.')
30
+
31
+ const model = getAiModel('anthropic')
32
+
33
+ const { schema: toolSchema, handler: toolHandler } = makeSearchTool(corpus, {
34
+ name: 'search_corpus',
35
+ description:
36
+ 'Search the accessibility finding corpus for entries related to a natural-language query. ' +
37
+ 'Call this before rewriting to find similar findings that demonstrate the expected voice, ' +
38
+ 'tone, and technical depth. Returns up to 3 matching entries.',
39
+ queryDescription: 'Natural-language search query, e.g. "keyboard focus visible" or "color contrast low vision".',
40
+ fields: CORPUS_SEARCH_FIELDS,
41
+ pick: CORPUS_PICK,
42
+ limit: 3,
43
+ })
44
+
45
+ const userPrompt = `Refine this accessibility finding based on the auditor's note.
46
+
47
+ Title: ${finding.title}
48
+ WCAG SC: ${finding.primarySC}
49
+ Severity: ${finding.severity}
50
+ Platform: ${finding.platform}
51
+
52
+ Current description:
53
+ ${descText}
54
+
55
+ Current suggested fix:
56
+ ${fixText}
57
+
58
+ Auditor's note: "${note}"
59
+
60
+ Search the corpus for related findings, then rewrite the description and suggested fix to reflect the refinement.`
61
+
62
+ const messages = [{ role: 'user', content: userPrompt }]
63
+
64
+ const text = await callAnthropicWithTools({
65
+ key,
66
+ model,
67
+ system: getSystemPrompt() || FALLBACK_SYSTEM_PROMPT,
68
+ tools: [toolSchema],
69
+ messages,
70
+ maxTokens: AI_AGENTIC_MAX_TOKENS,
71
+ maxTurns: AGENTIC_MAX_TOOL_TURNS,
72
+ onToolCall: toolHandler,
73
+ })
74
+
75
+ return parseAiResponse(text)
76
+ }
package/aiService.js CHANGED
@@ -1,31 +1,31 @@
1
- import { callProvider } from './fetch.js'
2
- import { getAdapter } from '@ulam/sawsawan'
3
- import { AI_MAX_TOKENS, AI_DESC_REGEX, AI_FIX_REGEX, LS_APIKEY_PREFIX } from './constants.js'
4
- import { getAiProvider, getAiModel } from './prefs.js'
5
- import { getBuildPrompt } from './init.js'
6
-
7
- export { AiApiError, httpStatusToErrorType } from './providers.js'
8
-
9
- export function parseAiResponse(text) {
10
- const descMatch = text.match(AI_DESC_REGEX)
11
- const fixMatch = text.match(AI_FIX_REGEX)
12
- return {
13
- desc: descMatch?.[1]?.trim() || null,
14
- fix: fixMatch?.[1]?.trim() || null,
15
- }
16
- }
17
-
18
- export async function getAiRefinement({ finding, descText, fixText, note }) {
19
- const buildPrompt = getBuildPrompt()
20
- if (!buildPrompt) throw new Error('halohalo: call initHalohalo({ buildPrompt }) before getAiRefinement')
21
-
22
- const provider = getAiProvider()
23
- const key = await getAdapter().getKey(`${LS_APIKEY_PREFIX}${provider}`)
24
-
25
- if (!key) throw new Error(`No API key found for ${provider}. Add one in Settings.`)
26
-
27
- const model = getAiModel(provider)
28
- const prompt = buildPrompt({ finding, descText, fixText, note })
29
- const text = await callProvider({ provider, model, key, prompt, maxTokens: AI_MAX_TOKENS })
30
- return parseAiResponse(text)
31
- }
1
+ import { callProvider } from './fetch.js'
2
+ import { AI_MAX_TOKENS, AI_DESC_REGEX, AI_FIX_REGEX, LS_APIKEY_PREFIX } from './constants.js'
3
+ import { getAiProvider, getAiModel } from './prefs.js'
4
+ import { getBuildPrompt } from './init.js'
5
+
6
+ export { AiApiError, httpStatusToErrorType } from './providers.js'
7
+
8
+ export function parseAiResponse(text) {
9
+ const descMatch = text.match(AI_DESC_REGEX)
10
+ const fixMatch = text.match(AI_FIX_REGEX)
11
+ return {
12
+ desc: descMatch?.[1]?.trim() || null,
13
+ fix: fixMatch?.[1]?.trim() || null,
14
+ }
15
+ }
16
+
17
+ export async function getAiRefinement({ finding, descText, fixText, note }) {
18
+ const buildPrompt = getBuildPrompt()
19
+ if (!buildPrompt) throw new Error('halohalo: call initHalohalo({ buildPrompt }) before getAiRefinement')
20
+
21
+ const provider = getAiProvider()
22
+ const { getAdapter } = await import('@ulam/sawsawan')
23
+ const key = await getAdapter().getKey(`${LS_APIKEY_PREFIX}${provider}`)
24
+
25
+ if (!key) throw new Error(`No API key found for ${provider}. Add one in Settings.`)
26
+
27
+ const model = getAiModel(provider)
28
+ const prompt = buildPrompt({ finding, descText, fixText, note })
29
+ const text = await callProvider({ provider, model, key, prompt, maxTokens: AI_MAX_TOKENS })
30
+ return parseAiResponse(text)
31
+ }
package/connectivity.js CHANGED
@@ -1,17 +1,17 @@
1
- /**
2
- * Probes a list of endpoints in parallel to check network reachability.
3
- *
4
- * @param {Array<{ label: string, url: string }>} probes
5
- * @param {number} [timeoutMs=4000]
6
- * @returns {Promise<Array<{ label: string, ok: boolean }>>}
7
- */
8
- export async function checkConnectivity(probes, timeoutMs = 4000) {
9
- const results = await Promise.allSettled(
10
- probes.map(p =>
11
- fetch(p.url, { method: 'HEAD', mode: 'no-cors', signal: AbortSignal.timeout(timeoutMs) })
12
- .then(() => ({ label: p.label, ok: true }))
13
- .catch(() => ({ label: p.label, ok: false }))
14
- )
15
- )
16
- return results.map(r => r.value || r.reason)
17
- }
1
+ /**
2
+ * Probes a list of endpoints in parallel to check network reachability.
3
+ *
4
+ * @param {Array<{ label: string, url: string }>} probes
5
+ * @param {number} [timeoutMs=4000]
6
+ * @returns {Promise<Array<{ label: string, ok: boolean }>>}
7
+ */
8
+ export async function checkConnectivity(probes, timeoutMs = 4000) {
9
+ const results = await Promise.allSettled(
10
+ probes.map(p =>
11
+ fetch(p.url, { method: 'HEAD', mode: 'no-cors', signal: AbortSignal.timeout(timeoutMs) })
12
+ .then(() => ({ label: p.label, ok: true }))
13
+ .catch(() => ({ label: p.label, ok: false }))
14
+ )
15
+ )
16
+ return results.map(r => r.value || r.reason)
17
+ }
@@ -1,113 +1,113 @@
1
- import { callProvider, callAnthropicWithTools } from './fetch.js'
2
- import { AiApiError } from './providers.js'
3
-
4
- const DEFAULT_TYPEWRITER = { tickMs: 33, minCharsPerTick: 2, charDivisor: 40 }
5
-
6
- /**
7
- * Vanilla completion runner. No React required.
8
- * Returns { complete(options), cancel() } plus a subscribe() for state changes.
9
- *
10
- * State: { loading: boolean, animating: boolean }
11
- *
12
- * options for complete():
13
- * provider, model, key, prompt, maxTokens: for standard completions
14
- * agentOptions: { system, tools, messages, maxTurns, onToolCall }: for agentic
15
- * onResult(fields) : called with parsed result after completion
16
- * onError(error) : called with AiApiError on failure
17
- * parseResponse(text) : maps raw text to result fields object
18
- * typewriter : { tickMs, minCharsPerTick, charDivisor } or false
19
- * onAnimate(field, text) : called each tick with field + current text slice
20
- * onAnimateDone() : called when typewriter finishes
21
- */
22
- export function createCompletion() {
23
- let loading = false
24
- let animating = false
25
- let timer = null
26
- const listeners = new Set()
27
- const notify = () => listeners.forEach(fn => fn({ loading, animating }))
28
-
29
- function runTypewriter(fields, { typewriter, onAnimate, onAnimateDone }) {
30
- clearTimeout(timer)
31
- const { tickMs, minCharsPerTick, charDivisor } = { ...DEFAULT_TYPEWRITER, ...typewriter }
32
-
33
- const entries = Object.entries(fields).filter(([, v]) => typeof v === 'string' && v.length > 0)
34
- if (!entries.length) {
35
- animating = false
36
- notify()
37
- onAnimateDone?.()
38
- return
39
- }
40
-
41
- const total = entries.reduce((sum, [, v]) => sum + v.length, 0)
42
- const charsPerTick = Math.max(minCharsPerTick, Math.ceil(total / charDivisor))
43
- const indices = Object.fromEntries(entries.map(([k]) => [k, 0]))
44
- let entryIdx = 0
45
-
46
- function tick() {
47
- const [field, text] = entries[entryIdx]
48
- indices[field] = Math.min(indices[field] + charsPerTick, text.length)
49
- onAnimate?.(field, text.slice(0, indices[field]))
50
- if (indices[field] >= text.length) entryIdx++
51
- if (entryIdx >= entries.length) {
52
- animating = false
53
- notify()
54
- onAnimateDone?.()
55
- } else {
56
- timer = setTimeout(tick, tickMs)
57
- }
58
- }
59
-
60
- timer = setTimeout(tick, tickMs)
61
- }
62
-
63
- return {
64
- get loading() { return loading },
65
- get animating() { return animating },
66
-
67
- subscribe(fn) {
68
- listeners.add(fn)
69
- return () => listeners.delete(fn)
70
- },
71
-
72
- async complete({ provider, model, key, prompt, maxTokens, agentOptions, onResult, onError, parseResponse, typewriter = DEFAULT_TYPEWRITER, onAnimate, onAnimateDone }) {
73
- loading = true
74
- notify()
75
-
76
- try {
77
- let text
78
- if (agentOptions) {
79
- const { system, tools, messages, maxTurns, onToolCall } = agentOptions
80
- text = await callAnthropicWithTools({ key, model, system, tools, messages, maxTokens, maxTurns, onToolCall })
81
- } else {
82
- text = await callProvider({ provider, model, key, prompt, maxTokens })
83
- }
84
-
85
- const result = parseResponse ? parseResponse(text) : { text }
86
- loading = false
87
-
88
- if (typewriter && onAnimate) {
89
- animating = true
90
- notify()
91
- onResult?.(result)
92
- runTypewriter(result, { typewriter, onAnimate, onAnimateDone })
93
- } else {
94
- notify()
95
- onResult?.(result)
96
- onAnimateDone?.()
97
- }
98
- } catch (e) {
99
- loading = false
100
- animating = false
101
- notify()
102
- onError?.(e instanceof AiApiError ? e : new AiApiError('api_error'))
103
- }
104
- },
105
-
106
- cancel() {
107
- clearTimeout(timer)
108
- loading = false
109
- animating = false
110
- notify()
111
- },
112
- }
113
- }
1
+ import { callProvider, callAnthropicWithTools } from './fetch.js'
2
+ import { AiApiError } from './providers.js'
3
+
4
+ const DEFAULT_TYPEWRITER = { tickMs: 33, minCharsPerTick: 2, charDivisor: 40 }
5
+
6
+ /**
7
+ * Vanilla completion runner. No React required.
8
+ * Returns { complete(options), cancel() } plus a subscribe() for state changes.
9
+ *
10
+ * State: { loading: boolean, animating: boolean }
11
+ *
12
+ * options for complete():
13
+ * provider, model, key, prompt, maxTokens: for standard completions
14
+ * agentOptions: { system, tools, messages, maxTurns, onToolCall }: for agentic
15
+ * onResult(fields) : called with parsed result after completion
16
+ * onError(error) : called with AiApiError on failure
17
+ * parseResponse(text) : maps raw text to result fields object
18
+ * typewriter : { tickMs, minCharsPerTick, charDivisor } or false
19
+ * onAnimate(field, text) : called each tick with field + current text slice
20
+ * onAnimateDone() : called when typewriter finishes
21
+ */
22
+ export function createCompletion() {
23
+ let loading = false
24
+ let animating = false
25
+ let timer = null
26
+ const listeners = new Set()
27
+ const notify = () => listeners.forEach(fn => fn({ loading, animating }))
28
+
29
+ function runTypewriter(fields, { typewriter, onAnimate, onAnimateDone }) {
30
+ clearTimeout(timer)
31
+ const { tickMs, minCharsPerTick, charDivisor } = { ...DEFAULT_TYPEWRITER, ...typewriter }
32
+
33
+ const entries = Object.entries(fields).filter(([, v]) => typeof v === 'string' && v.length > 0)
34
+ if (!entries.length) {
35
+ animating = false
36
+ notify()
37
+ onAnimateDone?.()
38
+ return
39
+ }
40
+
41
+ const total = entries.reduce((sum, [, v]) => sum + v.length, 0)
42
+ const charsPerTick = Math.max(minCharsPerTick, Math.ceil(total / charDivisor))
43
+ const indices = Object.fromEntries(entries.map(([k]) => [k, 0]))
44
+ let entryIdx = 0
45
+
46
+ function tick() {
47
+ const [field, text] = entries[entryIdx]
48
+ indices[field] = Math.min(indices[field] + charsPerTick, text.length)
49
+ onAnimate?.(field, text.slice(0, indices[field]))
50
+ if (indices[field] >= text.length) entryIdx++
51
+ if (entryIdx >= entries.length) {
52
+ animating = false
53
+ notify()
54
+ onAnimateDone?.()
55
+ } else {
56
+ timer = setTimeout(tick, tickMs)
57
+ }
58
+ }
59
+
60
+ timer = setTimeout(tick, tickMs)
61
+ }
62
+
63
+ return {
64
+ get loading() { return loading },
65
+ get animating() { return animating },
66
+
67
+ subscribe(fn) {
68
+ listeners.add(fn)
69
+ return () => listeners.delete(fn)
70
+ },
71
+
72
+ async complete({ provider, model, key, prompt, maxTokens, agentOptions, onResult, onError, parseResponse, typewriter = DEFAULT_TYPEWRITER, onAnimate, onAnimateDone }) {
73
+ loading = true
74
+ notify()
75
+
76
+ try {
77
+ let text
78
+ if (agentOptions) {
79
+ const { system, tools, messages, maxTurns, onToolCall } = agentOptions
80
+ text = await callAnthropicWithTools({ key, model, system, tools, messages, maxTokens, maxTurns, onToolCall })
81
+ } else {
82
+ text = await callProvider({ provider, model, key, prompt, maxTokens })
83
+ }
84
+
85
+ const result = parseResponse ? parseResponse(text) : { text }
86
+ loading = false
87
+
88
+ if (typewriter && onAnimate) {
89
+ animating = true
90
+ notify()
91
+ onResult?.(result)
92
+ runTypewriter(result, { typewriter, onAnimate, onAnimateDone })
93
+ } else {
94
+ notify()
95
+ onResult?.(result)
96
+ onAnimateDone?.()
97
+ }
98
+ } catch (e) {
99
+ loading = false
100
+ animating = false
101
+ notify()
102
+ onError?.(e instanceof AiApiError ? e : new AiApiError('api_error'))
103
+ }
104
+ },
105
+
106
+ cancel() {
107
+ clearTimeout(timer)
108
+ loading = false
109
+ animating = false
110
+ notify()
111
+ },
112
+ }
113
+ }
@@ -1,80 +1,85 @@
1
- import { DEFAULT_MODELS, DEFAULT_PROVIDERS, DEFAULT_PROVIDER_LABELS } from './providers.js'
2
- import { getAdapter } from '@ulam/sawsawan'
3
-
4
- /**
5
- * Vanilla provider config store. No React required.
6
- * Returns a plain object with getters, setters, and a subscribe() for change notifications.
7
- *
8
- * storageKeys: { provider, modelPrefix, keyPrefix, mode? }
9
- * providers: optional array of { id, label, defaultModel? }
10
- */
11
- export function createProviderConfig(storageKeys, providers = DEFAULT_PROVIDERS) {
12
- const { provider: providerKey, modelPrefix, keyPrefix, mode: modeKey } = storageKeys
13
-
14
- const providerList = providers.map(p =>
15
- typeof p === 'string' ? { id: p, label: DEFAULT_PROVIDER_LABELS[p] || p } : p
16
- )
17
-
18
- let provider = getAdapter().readPref(providerKey) || providerList[0]?.id || 'anthropic'
19
-
20
- let models = Object.fromEntries(
21
- providerList.map(p => [
22
- p.id,
23
- getAdapter().readPref(`${modelPrefix}${p.id}`) || p.defaultModel || DEFAULT_MODELS[p.id] || '',
24
- ])
25
- )
26
-
27
- let mode = modeKey ? getAdapter().readPref(modeKey) === 'true' : false
28
-
29
- const listeners = new Set()
30
- const notify = () => listeners.forEach(fn => fn())
31
-
32
- return {
33
- get provider() { return provider },
34
- get models() { return { ...models } },
35
- get mode() { return mode },
36
- get providers() { return providerList },
37
-
38
- setProvider(id) {
39
- getAdapter().writePref(providerKey, id)
40
- provider = id
41
- notify()
42
- },
43
-
44
- setModel(providerId, modelId) {
45
- getAdapter().writePref(`${modelPrefix}${providerId}`, modelId)
46
- models = { ...models, [providerId]: modelId }
47
- notify()
48
- },
49
-
50
- setMode(value) {
51
- if (!modeKey) return
52
- getAdapter().writePref(modeKey, value ? 'true' : 'false')
53
- mode = value
54
- notify()
55
- },
56
-
57
- async setKey(providerId, value) {
58
- await getAdapter().setKey(`${keyPrefix}${providerId}`, value)
59
- },
60
-
61
- async getKey(providerId) {
62
- return (await getAdapter().getKey(`${keyPrefix}${providerId}`)) || ''
63
- },
64
-
65
- getModel(providerId) {
66
- return models[providerId] || DEFAULT_MODELS[providerId] || ''
67
- },
68
-
69
- getLabel(providerId) {
70
- return providerList.find(p => p.id === providerId)?.label
71
- || DEFAULT_PROVIDER_LABELS[providerId]
72
- || providerId
73
- },
74
-
75
- subscribe(fn) {
76
- listeners.add(fn)
77
- return () => listeners.delete(fn)
78
- },
79
- }
80
- }
1
+ import { DEFAULT_MODELS, DEFAULT_PROVIDERS, DEFAULT_PROVIDER_LABELS } from './providers.js'
2
+
3
+ async function getAdapter() {
4
+ const mod = await import('@ulam/sawsawan')
5
+ return mod.getAdapter()
6
+ }
7
+
8
+ /**
9
+ * Vanilla provider config store. No React required.
10
+ * Returns a plain object with getters, setters, and a subscribe() for change notifications.
11
+ *
12
+ * storageKeys: { provider, modelPrefix, keyPrefix, mode? }
13
+ * providers: optional array of { id, label, defaultModel? }
14
+ */
15
+ export async function createProviderConfig(storageKeys, providers = DEFAULT_PROVIDERS) {
16
+ const { provider: providerKey, modelPrefix, keyPrefix, mode: modeKey } = storageKeys
17
+ const adapter = await getAdapter()
18
+
19
+ const providerList = providers.map(p =>
20
+ typeof p === 'string' ? { id: p, label: DEFAULT_PROVIDER_LABELS[p] || p } : p
21
+ )
22
+
23
+ let provider = adapter.readPref(providerKey) || providerList[0]?.id || 'anthropic'
24
+
25
+ let models = Object.fromEntries(
26
+ providerList.map(p => [
27
+ p.id,
28
+ adapter.readPref(`${modelPrefix}${p.id}`) || p.defaultModel || DEFAULT_MODELS[p.id] || '',
29
+ ])
30
+ )
31
+
32
+ let mode = modeKey ? adapter.readPref(modeKey) === 'true' : false
33
+
34
+ const listeners = new Set()
35
+ const notify = () => listeners.forEach(fn => fn())
36
+
37
+ return {
38
+ get provider() { return provider },
39
+ get models() { return { ...models } },
40
+ get mode() { return mode },
41
+ get providers() { return providerList },
42
+
43
+ setProvider(id) {
44
+ adapter.writePref(providerKey, id)
45
+ provider = id
46
+ notify()
47
+ },
48
+
49
+ setModel(providerId, modelId) {
50
+ adapter.writePref(`${modelPrefix}${providerId}`, modelId)
51
+ models = { ...models, [providerId]: modelId }
52
+ notify()
53
+ },
54
+
55
+ setMode(value) {
56
+ if (!modeKey) return
57
+ adapter.writePref(modeKey, value ? 'true' : 'false')
58
+ mode = value
59
+ notify()
60
+ },
61
+
62
+ async setKey(providerId, value) {
63
+ await adapter.setKey(`${keyPrefix}${providerId}`, value)
64
+ },
65
+
66
+ async getKey(providerId) {
67
+ return (await adapter.getKey(`${keyPrefix}${providerId}`)) || ''
68
+ },
69
+
70
+ getModel(providerId) {
71
+ return models[providerId] || DEFAULT_MODELS[providerId] || ''
72
+ },
73
+
74
+ getLabel(providerId) {
75
+ return providerList.find(p => p.id === providerId)?.label
76
+ || DEFAULT_PROVIDER_LABELS[providerId]
77
+ || providerId
78
+ },
79
+
80
+ subscribe(fn) {
81
+ listeners.add(fn)
82
+ return () => listeners.delete(fn)
83
+ },
84
+ }
85
+ }
package/fetch.js CHANGED
@@ -1,113 +1,134 @@
1
- import { AiApiError, httpStatusToErrorType, PROVIDER_CONFIGS } from './providers.js'
2
-
3
- // ─── callProvider ─────────────────────────────────────────────────────────────
4
- // Single-turn completion against any configured provider.
5
- // Returns the response text string.
6
-
7
- export async function callProvider({ provider, model, key, prompt, maxTokens = 1024 }) {
8
- const config = PROVIDER_CONFIGS[provider]
9
- if (!config) throw new AiApiError('api_error', { provider })
10
-
11
- let url
12
- if (config.buildUrl) {
13
- url = config.buildUrl(key, model)
14
- if (!url) throw new AiApiError('api_error', { provider })
15
- } else {
16
- url = config.url
17
- }
18
-
19
- let res
20
- try {
21
- res = await fetch(url, {
22
- method: 'POST',
23
- headers: config.buildHeaders(key),
24
- body: config.buildBody(prompt, model, maxTokens),
25
- })
26
- } catch {
27
- throw new AiApiError('network_error', { provider })
28
- }
29
-
30
- if (!res.ok) {
31
- throw new AiApiError(httpStatusToErrorType(res.status), { status: res.status, provider })
32
- }
33
-
34
- return config.parseResponse(res)
35
- }
36
-
37
- // ─── callAnthropicWithTools ───────────────────────────────────────────────────
38
- // Agentic tool-use loop for Anthropic. Calls the messages API repeatedly until
39
- // stop_reason is 'end_turn' or maxTurns is exhausted.
40
- //
41
- // onToolCall(toolName, toolInput) => toolResultContent (string or object)
42
- // Returns the final text block content.
43
-
44
- export async function callAnthropicWithTools({
45
- key,
46
- model,
47
- system,
48
- tools,
49
- messages,
50
- maxTokens = 2048,
51
- maxTurns = 5,
52
- onToolCall,
53
- }) {
54
- const config = PROVIDER_CONFIGS.anthropic
55
- let turns = 0
56
-
57
- while (turns <= maxTurns) {
58
- let res
59
- try {
60
- res = await fetch(config.url, {
61
- method: 'POST',
62
- headers: config.buildHeaders(key),
63
- body: JSON.stringify({ model, max_tokens: maxTokens, system, tools, messages }),
64
- })
65
- } catch {
66
- throw new AiApiError('network_error', { provider: 'anthropic' })
67
- }
68
-
69
- if (!res.ok) {
70
- throw new AiApiError(httpStatusToErrorType(res.status), { status: res.status, provider: 'anthropic' })
71
- }
72
-
73
- const data = await res.json()
74
-
75
- if (import.meta.env.DEV) console.log(`[halohalo] turn ${turns + 1}, stop_reason: ${data.stop_reason}`)
76
-
77
- messages.push({ role: 'assistant', content: data.content })
78
-
79
- if (data.stop_reason === 'end_turn') {
80
- return data.content.find(b => b.type === 'text')?.text || ''
81
- }
82
-
83
- if (data.stop_reason === 'tool_use') {
84
- if (turns >= maxTurns) {
85
- if (import.meta.env.DEV) console.warn('[halohalo] maxTurns reached')
86
- throw new AiApiError('api_error', { provider: 'anthropic' })
87
- }
88
-
89
- const toolBlocks = data.content.filter(b => b.type === 'tool_use')
90
- const toolResults = await Promise.all(
91
- toolBlocks.map(async (block) => {
92
- const result = await onToolCall?.(block.name, block.input) ?? ''
93
- if (import.meta.env.DEV) {
94
- console.log(`[halohalo] tool_use: ${block.name}`, block.input, '→', result)
95
- }
96
- return {
97
- type: 'tool_result',
98
- tool_use_id: block.id,
99
- content: typeof result === 'string' ? result : JSON.stringify(result),
100
- }
101
- })
102
- )
103
-
104
- messages.push({ role: 'user', content: toolResults })
105
- turns++
106
- continue
107
- }
108
-
109
- break
110
- }
111
-
112
- throw new AiApiError('api_error', { provider: 'anthropic' })
113
- }
1
+ import { AiApiError, httpStatusToErrorType, PROVIDER_CONFIGS } from './providers.js'
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
+
20
+ // ─── callProvider ─────────────────────────────────────────────────────────────
21
+ // Single-turn completion against any configured provider.
22
+ // Returns the response text string.
23
+
24
+ export async function callProvider({ provider, model, key, prompt, maxTokens = 1024 }) {
25
+ const config = PROVIDER_CONFIGS[provider]
26
+ if (!config) throw new AiApiError('api_error', { provider })
27
+
28
+ let url
29
+ if (config.buildUrl) {
30
+ url = config.buildUrl(key, model)
31
+ if (!url) throw new AiApiError('api_error', { provider })
32
+ } else {
33
+ url = config.url.replace('{model}', model)
34
+ }
35
+
36
+ if (!validateProviderUrl(url)) {
37
+ throw new AiApiError('api_error', { provider })
38
+ }
39
+
40
+ let res
41
+ try {
42
+ res = await fetch(url, {
43
+ method: 'POST',
44
+ headers: config.buildHeaders(key),
45
+ body: config.buildBody(prompt, model, maxTokens),
46
+ })
47
+ } catch {
48
+ throw new AiApiError('network_error', { provider })
49
+ }
50
+
51
+ if (!res.ok) {
52
+ throw new AiApiError(httpStatusToErrorType(res.status), { status: res.status, provider })
53
+ }
54
+
55
+ return config.parseResponse(res)
56
+ }
57
+
58
+ // ─── callAnthropicWithTools ───────────────────────────────────────────────────
59
+ // Agentic tool-use loop for Anthropic. Calls the messages API repeatedly until
60
+ // stop_reason is 'end_turn' or maxTurns is exhausted.
61
+ //
62
+ // onToolCall(toolName, toolInput) => toolResultContent (string or object)
63
+ // Returns the final text block content.
64
+
65
+ export async function callAnthropicWithTools({
66
+ key,
67
+ model,
68
+ system,
69
+ tools,
70
+ messages,
71
+ maxTokens = 2048,
72
+ maxTurns = 5,
73
+ onToolCall,
74
+ }) {
75
+ const config = PROVIDER_CONFIGS.anthropic
76
+ let turns = 0
77
+
78
+ while (turns <= maxTurns) {
79
+ let res
80
+ try {
81
+ res = await fetch(config.url, {
82
+ method: 'POST',
83
+ headers: config.buildHeaders(key),
84
+ body: JSON.stringify({ model, max_tokens: maxTokens, system, tools, messages }),
85
+ })
86
+ } catch {
87
+ throw new AiApiError('network_error', { provider: 'anthropic' })
88
+ }
89
+
90
+ if (!res.ok) {
91
+ throw new AiApiError(httpStatusToErrorType(res.status), { status: res.status, provider: 'anthropic' })
92
+ }
93
+
94
+ const data = await res.json()
95
+
96
+ if (import.meta.env.DEV) console.log(`[halohalo] turn ${turns + 1}, stop_reason: ${data.stop_reason}`)
97
+
98
+ messages.push({ role: 'assistant', content: data.content })
99
+
100
+ if (data.stop_reason === 'end_turn') {
101
+ return data.content.find(b => b.type === 'text')?.text || ''
102
+ }
103
+
104
+ if (data.stop_reason === 'tool_use') {
105
+ if (turns >= maxTurns) {
106
+ if (import.meta.env.DEV) console.warn('[halohalo] maxTurns reached')
107
+ throw new AiApiError('api_error', { provider: 'anthropic' })
108
+ }
109
+
110
+ const toolBlocks = data.content.filter(b => b.type === 'tool_use')
111
+ const toolResults = await Promise.all(
112
+ toolBlocks.map(async (block) => {
113
+ const result = await onToolCall?.(block.name, block.input) ?? ''
114
+ if (import.meta.env.DEV) {
115
+ console.log(`[halohalo] tool_use: ${block.name}`, block.input, '→', result)
116
+ }
117
+ return {
118
+ type: 'tool_result',
119
+ tool_use_id: block.id,
120
+ content: typeof result === 'string' ? result : JSON.stringify(result),
121
+ }
122
+ })
123
+ )
124
+
125
+ messages.push({ role: 'user', content: toolResults })
126
+ turns++
127
+ continue
128
+ }
129
+
130
+ break
131
+ }
132
+
133
+ throw new AiApiError('api_error', { provider: 'anthropic' })
134
+ }
package/index.js CHANGED
@@ -1,20 +1,20 @@
1
- // vanilla core
2
- export { initHalohalo } from './init.js'
3
- export { AiApiError, httpStatusToErrorType, PROVIDER_CONFIGS } from './providers.js'
4
- export { callProvider, callAnthropicWithTools } from './fetch.js'
5
- export { searchItems, makeSearchTool } from './search.js'
6
- export { createProviderConfig } from './createProviderConfig.js'
7
- export { createCompletion } from './createCompletion.js'
8
- export { getAiRefinement, parseAiResponse } from './aiService.js'
9
- export { getAgenticRefinement } from './agenticAiService.js'
10
- export { PROVIDERS, PROVIDER_MODELS, initModels, initApiKeys } from './models.js'
11
- export { getAiProvider, isAgenticModeEnabled, getAiModel, getProviderLabel } from './prefs.js'
12
- export { checkConnectivity } from './connectivity.js'
13
- export {
14
- LS_AI_PROVIDER, LS_AGENTIC_MODE, LS_APIKEY_PREFIX, LS_AI_MODEL_PREFIX,
15
- AI_MAX_TOKENS, AI_AGENTIC_MAX_TOKENS, AGENTIC_MAX_TOOL_TURNS,
16
- AI_DESC_REGEX, AI_FIX_REGEX,
17
- DEBUG_AI_DELAY_MS, DEFAULT_AI_MODELS, PROVIDER_LABELS,
18
- DEBUG_COMMANDS, DEBUG_COMMAND_VALUES,
19
- ANTHROPIC_API_VERSION, ANTHROPIC_API_URL,
20
- } from './constants.js'
1
+ // vanilla core
2
+ export { initHalohalo } from './init.js'
3
+ export { AiApiError, httpStatusToErrorType, PROVIDER_CONFIGS } from './providers.js'
4
+ export { callProvider, callAnthropicWithTools } from './fetch.js'
5
+ export { searchItems, makeSearchTool } from './search.js'
6
+ export { createProviderConfig } from './createProviderConfig.js'
7
+ export { createCompletion } from './createCompletion.js'
8
+ export { getAiRefinement, parseAiResponse } from './aiService.js'
9
+ export { getAgenticRefinement } from './agenticAiService.js'
10
+ export { PROVIDERS, PROVIDER_MODELS, initModels, initApiKeys } from './models.js'
11
+ export { getAiProvider, isAgenticModeEnabled, getAiModel, getProviderLabel } from './prefs.js'
12
+ export { checkConnectivity } from './connectivity.js'
13
+ export {
14
+ LS_AI_PROVIDER, LS_AGENTIC_MODE, LS_APIKEY_PREFIX, LS_AI_MODEL_PREFIX,
15
+ AI_MAX_TOKENS, AI_AGENTIC_MAX_TOKENS, AGENTIC_MAX_TOOL_TURNS,
16
+ AI_DESC_REGEX, AI_FIX_REGEX,
17
+ DEBUG_AI_DELAY_MS, DEFAULT_AI_MODELS, PROVIDER_LABELS,
18
+ DEBUG_COMMANDS, DEBUG_COMMAND_VALUES,
19
+ ANTHROPIC_API_VERSION, ANTHROPIC_API_URL,
20
+ } from './constants.js'
package/init.js CHANGED
@@ -1,33 +1,33 @@
1
- // ─── halohalo module singleton ────────────────────────────────────────────────
2
- // Holds app-injected config. Call initHalohalo() before using aiService or
3
- // agenticAiService.
4
-
5
- let _buildPrompt = null
6
- let _systemPrompt = null
7
-
8
- /**
9
- * Register app-specific AI config. Call once at app init.
10
- *
11
- * @param {{
12
- * buildPrompt: (params: object) => string,
13
- * systemPrompt?: string,
14
- * }} config
15
- *
16
- * buildPrompt receives { finding, descText, fixText, note } and must return
17
- * the user-turn prompt string for single-turn refinement.
18
- *
19
- * systemPrompt is used for agentic (tool-use) mode. If omitted, agentic mode
20
- * falls back to a generic instruction.
21
- */
22
- export function initHalohalo({ buildPrompt, systemPrompt = null }) {
23
- _buildPrompt = buildPrompt
24
- _systemPrompt = systemPrompt
25
- }
26
-
27
- export function getBuildPrompt() {
28
- return _buildPrompt
29
- }
30
-
31
- export function getSystemPrompt() {
32
- return _systemPrompt
33
- }
1
+ // ─── halohalo module singleton ────────────────────────────────────────────────
2
+ // Holds app-injected config. Call initHalohalo() before using aiService or
3
+ // agenticAiService.
4
+
5
+ let _buildPrompt = null
6
+ let _systemPrompt = null
7
+
8
+ /**
9
+ * Register app-specific AI config. Call once at app init.
10
+ *
11
+ * @param {{
12
+ * buildPrompt: (params: object) => string,
13
+ * systemPrompt?: string,
14
+ * }} config
15
+ *
16
+ * buildPrompt receives { finding, descText, fixText, note } and must return
17
+ * the user-turn prompt string for single-turn refinement.
18
+ *
19
+ * systemPrompt is used for agentic (tool-use) mode. If omitted, agentic mode
20
+ * falls back to a generic instruction.
21
+ */
22
+ export function initHalohalo({ buildPrompt, systemPrompt = null }) {
23
+ _buildPrompt = buildPrompt
24
+ _systemPrompt = systemPrompt
25
+ }
26
+
27
+ export function getBuildPrompt() {
28
+ return _buildPrompt
29
+ }
30
+
31
+ export function getSystemPrompt() {
32
+ return _systemPrompt
33
+ }
package/models.js CHANGED
@@ -1,38 +1,48 @@
1
- import { getAdapter } from '@ulam/sawsawan'
2
- import { LS_AI_MODEL_PREFIX, LS_APIKEY_PREFIX, DEFAULT_AI_MODELS } from './constants.js'
3
-
4
- export const PROVIDERS = [
5
- { id: 'anthropic', label: 'Anthropic (Claude)', placeholderKey: 'settings.api_placeholder_anthropic' },
6
- { id: 'openai', label: 'OpenAI (GPT)', placeholderKey: 'settings.api_placeholder_openai' },
7
- { id: 'google', label: 'Google (Gemini)', placeholderKey: 'settings.api_placeholder_google' },
8
- { id: 'microsoft', label: 'Microsoft (Copilot)', placeholderKey: 'settings.api_placeholder_default' },
9
- ]
10
-
11
- export const PROVIDER_MODELS = {
12
- anthropic: [
13
- { id: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5, fast, low cost' },
14
- { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6, balanced (default)' },
15
- { id: 'claude-opus-4-7', label: 'Claude Opus 4.7, most capable' },
16
- ],
17
- openai: [
18
- { id: 'gpt-4o-mini', label: 'GPT-4o Mini, fast, low cost' },
19
- { id: 'gpt-4o', label: 'GPT-4o, balanced (default)' },
20
- ],
21
- google: [
22
- { id: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash, fast (default)' },
23
- { id: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro, most capable' },
24
- ],
25
- microsoft: [],
26
- }
27
-
28
- export function initModels() {
29
- return Object.fromEntries(
30
- PROVIDERS.map(p => [p.id, getAdapter().readPref(`${LS_AI_MODEL_PREFIX}${p.id}`) || DEFAULT_AI_MODELS[p.id] || ''])
31
- )
32
- }
33
-
34
- export function initApiKeys() {
35
- return Object.fromEntries(
36
- PROVIDERS.map(p => [p.id, window.electronAPI ? '' : getAdapter().readPref(`${LS_APIKEY_PREFIX}${p.id}`) || ''])
37
- )
38
- }
1
+ import { LS_AI_MODEL_PREFIX, LS_APIKEY_PREFIX, DEFAULT_AI_MODELS } from './constants.js'
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
+
12
+ export const PROVIDERS = [
13
+ { id: 'anthropic', label: 'Anthropic (Claude)', placeholderKey: 'settings.api_placeholder_anthropic' },
14
+ { id: 'openai', label: 'OpenAI (GPT)', placeholderKey: 'settings.api_placeholder_openai' },
15
+ { id: 'google', label: 'Google (Gemini)', placeholderKey: 'settings.api_placeholder_google' },
16
+ { id: 'microsoft', label: 'Microsoft (Copilot)', placeholderKey: 'settings.api_placeholder_default' },
17
+ ]
18
+
19
+ export const PROVIDER_MODELS = {
20
+ anthropic: [
21
+ { id: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5, fast, low cost' },
22
+ { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6, balanced (default)' },
23
+ { id: 'claude-opus-4-7', label: 'Claude Opus 4.7, most capable' },
24
+ ],
25
+ openai: [
26
+ { id: 'gpt-4o-mini', label: 'GPT-4o Mini, fast, low cost' },
27
+ { id: 'gpt-4o', label: 'GPT-4o, balanced (default)' },
28
+ ],
29
+ google: [
30
+ { id: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash, fast (default)' },
31
+ { id: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro, most capable' },
32
+ ],
33
+ microsoft: [],
34
+ }
35
+
36
+ export function initModels() {
37
+ const adapter = getAdapterInstance()
38
+ return Object.fromEntries(
39
+ PROVIDERS.map(p => [p.id, adapter.readPref(`${LS_AI_MODEL_PREFIX}${p.id}`) || DEFAULT_AI_MODELS[p.id] || ''])
40
+ )
41
+ }
42
+
43
+ export function initApiKeys() {
44
+ const adapter = getAdapterInstance()
45
+ return Object.fromEntries(
46
+ PROVIDERS.map(p => [p.id, window.electronAPI ? '' : adapter.readPref(`${LS_APIKEY_PREFIX}${p.id}`) || ''])
47
+ )
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulam/halohalo",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "AI service adapters, model configuration, and provider abstraction.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/providers.js CHANGED
@@ -60,9 +60,11 @@ export const PROVIDER_CONFIGS = {
60
60
  },
61
61
 
62
62
  google: {
63
- buildUrl: (key, model) =>
64
- `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`,
65
- buildHeaders: () => ({ 'Content-Type': 'application/json' }),
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/react.js CHANGED
@@ -1,2 +1,2 @@
1
- export { useProviderConfig } from './useProviderConfig.js'
2
- export { useCompletion } from './useCompletion.js'
1
+ export { useProviderConfig } from './useProviderConfig.js'
2
+ export { useCompletion } from './useCompletion.js'
package/useCompletion.js CHANGED
@@ -1,17 +1,17 @@
1
- import { useState, useEffect } from 'react'
2
- import { createCompletion } from './createCompletion.js'
3
-
4
- export function useCompletion(options = {}) {
5
- const [instance] = useState(createCompletion)
6
- const [state, setState] = useState({ loading: false, animating: false })
7
-
8
- useEffect(() => instance.subscribe(setState), [instance])
9
- useEffect(() => () => instance.cancel(), [instance])
10
-
11
- return {
12
- loading: state.loading,
13
- animating: state.animating,
14
- complete: (callOptions) => instance.complete({ ...options, ...callOptions }),
15
- cancel: () => instance.cancel(),
16
- }
17
- }
1
+ import { useState, useEffect } from 'react'
2
+ import { createCompletion } from './createCompletion.js'
3
+
4
+ export function useCompletion(options = {}) {
5
+ const [instance] = useState(createCompletion)
6
+ const [state, setState] = useState({ loading: false, animating: false })
7
+
8
+ useEffect(() => instance.subscribe(setState), [instance])
9
+ useEffect(() => () => instance.cancel(), [instance])
10
+
11
+ return {
12
+ loading: state.loading,
13
+ animating: state.animating,
14
+ complete: (callOptions) => instance.complete({ ...options, ...callOptions }),
15
+ cancel: () => instance.cancel(),
16
+ }
17
+ }
@@ -1,22 +1,27 @@
1
- import { useState, useCallback, useEffect } from 'react'
2
- import { createProviderConfig } from './createProviderConfig.js'
3
-
4
- export function useProviderConfig(storageKeys, providers) {
5
- const [config] = useState(() => createProviderConfig(storageKeys, providers))
6
- const [, rerender] = useState(0)
7
- useEffect(() => config.subscribe(() => rerender(n => n + 1)), [config])
8
-
9
- return {
10
- provider: config.provider,
11
- models: config.models,
12
- mode: config.mode,
13
- providers: config.providers,
14
- setProvider: useCallback((id) => config.setProvider(id), [config]),
15
- setModel: useCallback((pid, mid) => config.setModel(pid, mid), [config]),
16
- setMode: useCallback((v) => config.setMode(v), [config]),
17
- setKey: useCallback((pid, v) => config.setKey(pid, v), [config]),
18
- getKey: useCallback((pid) => config.getKey(pid), [config]),
19
- getModel: useCallback((pid) => config.getModel(pid), [config]),
20
- getLabel: useCallback((pid) => config.getLabel(pid), [config]),
21
- }
22
- }
1
+ import { useState, useCallback, useSyncExternalStore } from 'react'
2
+ import { createProviderConfig } from './createProviderConfig.js'
3
+
4
+ export function useProviderConfig(storageKeys, providers) {
5
+ const [config] = useState(() => createProviderConfig(storageKeys, providers))
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
+ )
16
+
17
+ return {
18
+ ...snapshot,
19
+ setProvider: useCallback((id) => config.setProvider(id), [config]),
20
+ setModel: useCallback((pid, mid) => config.setModel(pid, mid), [config]),
21
+ setMode: useCallback((v) => config.setMode(v), [config]),
22
+ setKey: useCallback((pid, v) => config.setKey(pid, v), [config]),
23
+ getKey: useCallback((pid) => config.getKey(pid), [config]),
24
+ getModel: useCallback((pid) => config.getModel(pid), [config]),
25
+ getLabel: useCallback((pid) => config.getLabel(pid), [config]),
26
+ }
27
+ }