@ulam/halohalo 0.3.0 → 0.3.3

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,193 +1,212 @@
1
- # @ulam/halohalo
2
-
3
- AI service adapters, model configuration, and provider abstraction. Vanilla core with a React hooks adapter.
4
-
5
- Named for halo-halo, the Filipino shaved ice dessert: a mix of many things that somehow works together.
6
-
7
- ## The ulam framework
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
- ```
18
-
19
- ## Install
20
-
21
- ```bash
22
- npm install @ulam/halohalo
23
- ```
24
-
25
- Peer dependencies: `fuse.js >= 7` (for search). Framework adapters add `react >= 18`, `vue >= 3`, or `@angular/core >= 17` as needed.
26
-
27
- ## Supported providers
28
-
29
- - Anthropic (Claude)
30
- - OpenAI (GPT)
31
- - Google (Gemini)
32
-
33
- Bring your own API key. Keys stay in the browser via localStorage and are sent directly to the provider, never to a server.
34
-
35
- ## Usage
36
-
37
- ### Initialize
38
-
39
- ```js
40
- import { initApiKeys, initModels, getAiProvider } from '@ulam/halohalo'
41
-
42
- initApiKeys({ anthropic: 'sk-ant-...', openai: 'sk-...' })
43
- initModels({ anthropic: 'claude-sonnet-4-6', openai: 'gpt-4o' })
44
-
45
- const provider = getAiProvider() // 'anthropic' | 'openai' | 'google'
46
- ```
47
-
48
- ### Call a provider
49
-
50
- ```js
51
- import { createCompletion } from '@ulam/halohalo'
52
-
53
- const result = await createCompletion({
54
- prompt: 'Rewrite this finding for a mobile context.',
55
- systemPrompt: 'You are an accessibility audit assistant.',
56
- })
57
- ```
58
-
59
- ### Anthropic with tool use
60
-
61
- ```js
62
- import { callAnthropicWithTools } from '@ulam/halohalo'
63
-
64
- const result = await callAnthropicWithTools({
65
- messages,
66
- tools,
67
- system: 'You are an accessibility audit assistant.',
68
- })
69
- ```
70
-
71
- ### Agentic mode
72
-
73
- Agentic mode uses tool calling to search the corpus for similar past revisions before rewriting, matching the tone and depth of established work.
74
-
75
- ```js
76
- import { getAgenticRefinement } from '@ulam/halohalo'
77
-
78
- const revised = await getAgenticRefinement({
79
- finding,
80
- notes: 'This is specific to mobile, element is a tooltip',
81
- })
82
- ```
83
-
84
- ### React hooks adapter
85
-
86
- ```jsx
87
- import { useProviderConfig, useCompletion } from '@ulam/halohalo/react'
88
-
89
- function AISettings() {
90
- const { provider, model, setProvider } = useProviderConfig()
91
- const { complete, loading } = useCompletion()
92
-
93
- return (
94
- <button onClick={() => complete('Rewrite this for mobile.')}>
95
- {loading ? 'Revising...' : 'Rewrite'}
96
- </button>
97
- )
98
- }
99
- ```
100
-
101
- ### Vue composables adapter
102
-
103
- `@ulam/halohalo/vue` provides composables that wrap the vanilla `createCompletion` and `createProviderConfig` with Vue reactivity. Loading and animating state are `readonly` refs that update as the completion runs.
104
-
105
- ```js
106
- import { useCompletion, useProviderConfig } from '@ulam/halohalo/vue'
107
- import { onUnmounted } from 'vue'
108
-
109
- // In setup()
110
- const { loading, animating, complete, cancel, cleanup } = useCompletion()
111
- onUnmounted(cleanup)
112
-
113
- // loading.value and animating.value are reactive
114
- await complete({ prompt: 'Rewrite this for mobile.' })
115
- ```
116
-
117
- `useProviderConfig()` returns reactive refs for `provider`, `models`, and `mode`, plus all setter functions from the vanilla config store.
118
-
119
- ```js
120
- const { provider, setProvider } = useProviderConfig()
121
- // provider.value is reactive
122
- setProvider('openai')
123
- ```
124
-
125
- Both composables return a `cleanup` function. Call it in `onUnmounted()` if the composable is used inside a component. For app-level use outside a component, cleanup is optional.
126
-
127
- ### Angular services adapter
128
-
129
- `@ulam/halohalo/angular` provides two injectable services backed by Angular signals.
130
-
131
- **`CompletionService`** is not `providedIn: 'root'`. Each injection creates a separate completion instance with its own loading state. Provide it at the component level for scoped instances, or at the application level for a shared one.
132
-
133
- ```ts
134
- import { CompletionService, ProviderConfigService, provideHalohalo } from '@ulam/halohalo/angular'
135
-
136
- // Application root (shared singleton):
137
- bootstrapApplication(AppComponent, {
138
- providers: [provideHalohalo()]
139
- })
140
-
141
- // Or component-level (scoped per component):
142
- @Component({
143
- providers: [CompletionService],
144
- template: `
145
- <button (click)="rewrite()" [disabled]="completion.loading()">
146
- {{ completion.loading() ? 'Revising...' : 'Rewrite' }}
147
- </button>
148
- `
149
- })
150
- export class RewriteButtonComponent {
151
- completion = inject(CompletionService)
152
-
153
- async rewrite() {
154
- await this.completion.complete({ prompt: 'Rewrite for mobile.' })
155
- }
156
- }
157
- ```
158
-
159
- `completion.loading()` and `completion.animating()` are Angular `computed` signals that work directly in templates and in `effect()` calls.
160
-
161
- **`ProviderConfigService`** is `providedIn: 'root'`, giving you one config store for the whole app.
162
-
163
- ```ts
164
- @Component({ ... })
165
- export class SettingsComponent {
166
- providerConfig = inject(ProviderConfigService)
167
-
168
- // In template:
169
- // {{ providerConfig.provider() }}
170
- // (click)="providerConfig.setProvider('openai')"
171
- }
172
- ```
173
-
174
- ## Design
175
-
176
- **Bring your own key.** No proxy, no server, no account. API keys are stored in localStorage and sent directly to the provider.
177
-
178
- **Provider-agnostic core.** `createCompletion` works the same regardless of which provider is active. Switch providers without changing call sites.
179
-
180
- **Vanilla-first.** The core has no framework dependency. Import from `/react`, `/vue`, or `/angular` only when you need framework reactivity.
181
-
182
- ## Subpath exports
183
-
184
- | Import | Contents |
185
- | ------ | -------- |
186
- | `@ulam/halohalo` | Vanilla core: `createCompletion`, `createProviderConfig`, `callProvider`, `callAnthropicWithTools`, `getAgenticRefinement`, `searchItems`, `makeSearchTool`, and more |
187
- | `@ulam/halohalo/react` | `useCompletion`, `useProviderConfig` |
188
- | `@ulam/halohalo/vue` | `useCompletion`, `useProviderConfig` |
189
- | `@ulam/halohalo/angular` | `CompletionService`, `ProviderConfigService`, `provideHalohalo` |
190
-
191
- ## License
192
-
193
- MIT
1
+ # @ulam/halohalo
2
+
3
+ AI service adapters, model configuration, and provider abstraction. Vanilla core with React, Vue, and Angular adapters.
4
+
5
+ Named for halo-halo, the Filipino shaved ice dessert: a mix of many things that somehow works together.
6
+
7
+ ## Purpose & Scope
8
+
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.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ npm install @ulam/halohalo
44
+ ```
45
+
46
+ Peer dependencies: `fuse.js >= 7` (for search). Framework adapters add `react >= 18`, `vue >= 3`, or `@angular/core >= 17` as needed.
47
+
48
+ ## Supported providers
49
+
50
+ - Anthropic (Claude)
51
+ - OpenAI (GPT)
52
+ - Google (Gemini)
53
+
54
+ Bring your own API key. Keys stay in the browser via localStorage and are sent directly to the provider, never to a server.
55
+
56
+ ## Usage
57
+
58
+ ### Initialize
59
+
60
+ ```js
61
+ import { initApiKeys, initModels, getAiProvider } from '@ulam/halohalo'
62
+
63
+ initApiKeys({ anthropic: 'sk-ant-...', openai: 'sk-...' })
64
+ initModels({ anthropic: 'claude-sonnet-4-6', openai: 'gpt-4o' })
65
+
66
+ const provider = getAiProvider() // 'anthropic' | 'openai' | 'google'
67
+ ```
68
+
69
+ ### Call a provider
70
+
71
+ ```js
72
+ import { createCompletion } from '@ulam/halohalo'
73
+
74
+ const result = await createCompletion({
75
+ prompt: 'Rewrite this finding for a mobile context.',
76
+ systemPrompt: 'You are an accessibility audit assistant.',
77
+ })
78
+ ```
79
+
80
+ ### Anthropic with tool use
81
+
82
+ ```js
83
+ import { callAnthropicWithTools } from '@ulam/halohalo'
84
+
85
+ const result = await callAnthropicWithTools({
86
+ messages,
87
+ tools,
88
+ system: 'You are an accessibility audit assistant.',
89
+ })
90
+ ```
91
+
92
+ ### Agentic mode
93
+
94
+ Agentic mode uses tool calling to search the corpus for similar past revisions before rewriting, matching the tone and depth of established work.
95
+
96
+ ```js
97
+ import { getAgenticRefinement } from '@ulam/halohalo'
98
+
99
+ const revised = await getAgenticRefinement({
100
+ finding,
101
+ notes: 'This is specific to mobile, element is a tooltip',
102
+ })
103
+ ```
104
+
105
+ ### React hooks adapter
106
+
107
+ ```jsx
108
+ import { useProviderConfig, useCompletion } from '@ulam/halohalo/react'
109
+
110
+ function AISettings() {
111
+ const { provider, model, setProvider } = useProviderConfig()
112
+ const { complete, loading } = useCompletion()
113
+
114
+ return (
115
+ <button onClick={() => complete('Rewrite this for mobile.')}>
116
+ {loading ? 'Revising...' : 'Rewrite'}
117
+ </button>
118
+ )
119
+ }
120
+ ```
121
+
122
+ ### Vue composables adapter
123
+
124
+ `@ulam/halohalo/vue` provides composables that wrap the vanilla `createCompletion` and `createProviderConfig` with Vue reactivity. Loading and animating state are `readonly` refs that update as the completion runs.
125
+
126
+ ```js
127
+ import { useCompletion, useProviderConfig } from '@ulam/halohalo/vue'
128
+ import { onUnmounted } from 'vue'
129
+
130
+ // In setup()
131
+ const { loading, animating, complete, cancel, cleanup } = useCompletion()
132
+ onUnmounted(cleanup)
133
+
134
+ // loading.value and animating.value are reactive
135
+ await complete({ prompt: 'Rewrite this for mobile.' })
136
+ ```
137
+
138
+ `useProviderConfig()` returns reactive refs for `provider`, `models`, and `mode`, plus all setter functions from the vanilla config store.
139
+
140
+ ```js
141
+ const { provider, setProvider } = useProviderConfig()
142
+ // provider.value is reactive
143
+ setProvider('openai')
144
+ ```
145
+
146
+ Both composables return a `cleanup` function. Call it in `onUnmounted()` if the composable is used inside a component. For app-level use outside a component, cleanup is optional.
147
+
148
+ ### Angular services adapter
149
+
150
+ `@ulam/halohalo/angular` provides two injectable services backed by Angular signals.
151
+
152
+ **`CompletionService`** is not `providedIn: 'root'`. Each injection creates a separate completion instance with its own loading state. Provide it at the component level for scoped instances, or at the application level for a shared one.
153
+
154
+ ```ts
155
+ import { CompletionService, ProviderConfigService, provideHalohalo } from '@ulam/halohalo/angular'
156
+
157
+ // Application root (shared singleton):
158
+ bootstrapApplication(AppComponent, {
159
+ providers: [provideHalohalo()]
160
+ })
161
+
162
+ // Or component-level (scoped per component):
163
+ @Component({
164
+ providers: [CompletionService],
165
+ template: `
166
+ <button (click)="rewrite()" [disabled]="completion.loading()">
167
+ {{ completion.loading() ? 'Revising...' : 'Rewrite' }}
168
+ </button>
169
+ `
170
+ })
171
+ export class RewriteButtonComponent {
172
+ completion = inject(CompletionService)
173
+
174
+ async rewrite() {
175
+ await this.completion.complete({ prompt: 'Rewrite for mobile.' })
176
+ }
177
+ }
178
+ ```
179
+
180
+ `completion.loading()` and `completion.animating()` are Angular `computed` signals that work directly in templates and in `effect()` calls.
181
+
182
+ **`ProviderConfigService`** is `providedIn: 'root'`, giving you one config store for the whole app.
183
+
184
+ ```ts
185
+ @Component({ ... })
186
+ export class SettingsComponent {
187
+ providerConfig = inject(ProviderConfigService)
188
+
189
+ // In template:
190
+ // {{ providerConfig.provider() }}
191
+ // (click)="providerConfig.setProvider('openai')"
192
+ }
193
+ ```
194
+
195
+ ## Design
196
+
197
+ **Bring your own key.** No proxy, no server, no account. API keys are stored in localStorage and sent directly to the provider.
198
+
199
+ **Provider-agnostic core.** `createCompletion` works the same regardless of which provider is active. Switch providers without changing call sites.
200
+
201
+ **Vanilla-first.** The core has no framework dependency. Import from `/react`, `/vue`, or `/angular` only when you need framework reactivity.
202
+
203
+ ## Subpath exports
204
+
205
+ | Import | Contents |
206
+ | ------ | -------- |
207
+ | `@ulam/halohalo` | Vanilla core: `createCompletion`, `createProviderConfig`, `callProvider`, `callAnthropicWithTools`, `getAgenticRefinement`, `searchItems`, `makeSearchTool`, and more |
208
+ | `@ulam/halohalo/react` | `useCompletion`, `useProviderConfig` |
209
+ | `@ulam/halohalo/vue` | `useCompletion`, `useProviderConfig` |
210
+ | `@ulam/halohalo/angular` | `CompletionService`, `ProviderConfigService`, `provideHalohalo` |
211
+
212
+ See the [root README](../../README.md) for a complete framework support overview across all ulam packages.
@@ -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/angular.js CHANGED
@@ -1,102 +1,102 @@
1
- /**
2
- * @ulam/halohalo/angular: Angular adapter
3
- *
4
- * Injectable services wrapping the vanilla halohalo core with Angular signal
5
- * reactivity. Provide once at the application root via provideHalohalo().
6
- */
7
- import { Injectable, signal, computed } from '@angular/core'
8
- import { createCompletion } from './createCompletion.js'
9
- import { createProviderConfig } from './createProviderConfig.js'
10
-
11
- /**
12
- * Service for AI completions. Each inject() call gets its own completion
13
- * instance; loading/animating state is per-instance.
14
- *
15
- * Usage:
16
- * private completion = inject(CompletionService)
17
- *
18
- * await this.completion.complete({ prompt: 'Rewrite for mobile.' })
19
- * // this.completion.loading() is true while running
20
- */
21
- @Injectable()
22
- export class CompletionService {
23
- #instance = createCompletion()
24
- #loading = signal(false)
25
- #animating = signal(false)
26
-
27
- loading = computed(() => this.#loading())
28
- animating = computed(() => this.#animating())
29
-
30
- constructor() {
31
- this.#instance.subscribe(({ loading, animating }) => {
32
- this.#loading.set(loading)
33
- this.#animating.set(animating)
34
- })
35
- }
36
-
37
- complete(options) {
38
- return this.#instance.complete(options)
39
- }
40
-
41
- cancel() {
42
- this.#instance.cancel()
43
- }
44
-
45
- ngOnDestroy() {
46
- this.#instance.cancel()
47
- }
48
- }
49
-
50
- /**
51
- * Service for AI provider and model configuration. One config store per application.
52
- *
53
- * Usage:
54
- * private providerConfig = inject(ProviderConfigService)
55
- *
56
- * this.providerConfig.setProvider('openai')
57
- * const current = this.providerConfig.provider() // Signal<string>
58
- */
59
- @Injectable({ providedIn: 'root' })
60
- export class ProviderConfigService {
61
- #config = createProviderConfig()
62
- #provider = signal(this.#config.provider)
63
- #models = signal(this.#config.models)
64
- #mode = signal(this.#config.mode)
65
-
66
- provider = computed(() => this.#provider())
67
- models = computed(() => this.#models())
68
- mode = computed(() => this.#mode())
69
- providers = this.#config.providers
70
-
71
- constructor() {
72
- this.#config.subscribe(() => {
73
- this.#provider.set(this.#config.provider)
74
- this.#models.set(this.#config.models)
75
- this.#mode.set(this.#config.mode)
76
- })
77
- }
78
-
79
- setProvider(id) { this.#config.setProvider(id) }
80
- setModel(pid, mid) { this.#config.setModel(pid, mid) }
81
- setMode(v) { this.#config.setMode(v) }
82
- setKey(pid, v) { return this.#config.setKey(pid, v) }
83
- getKey(pid) { return this.#config.getKey(pid) }
84
- getModel(pid) { return this.#config.getModel(pid) }
85
- getLabel(pid) { return this.#config.getLabel(pid) }
86
- }
87
-
88
- /**
89
- * Standalone provider function. Use in bootstrapApplication() or as a
90
- * component-level provider when you need scoped completion instances.
91
- *
92
- * App-level (shared singleton):
93
- * bootstrapApplication(AppComponent, {
94
- * providers: [provideHalohalo()]
95
- * })
96
- *
97
- * Component-level (scoped instance):
98
- * @Component({ providers: [CompletionService] })
99
- */
100
- export function provideHalohalo() {
101
- return [ProviderConfigService, CompletionService]
102
- }
1
+ /**
2
+ * @ulam/halohalo/angular: Angular adapter
3
+ *
4
+ * Injectable services wrapping the vanilla halohalo core with Angular signal
5
+ * reactivity. Provide once at the application root via provideHalohalo().
6
+ */
7
+ import { Injectable, signal, computed } from '@angular/core'
8
+ import { createCompletion } from './createCompletion.js'
9
+ import { createProviderConfig } from './createProviderConfig.js'
10
+
11
+ /**
12
+ * Service for AI completions. Each inject() call gets its own completion
13
+ * instance; loading/animating state is per-instance.
14
+ *
15
+ * Usage:
16
+ * private completion = inject(CompletionService)
17
+ *
18
+ * await this.completion.complete({ prompt: 'Rewrite for mobile.' })
19
+ * // this.completion.loading() is true while running
20
+ */
21
+ @Injectable()
22
+ export class CompletionService {
23
+ #instance = createCompletion()
24
+ #loading = signal(false)
25
+ #animating = signal(false)
26
+
27
+ loading = computed(() => this.#loading())
28
+ animating = computed(() => this.#animating())
29
+
30
+ constructor() {
31
+ this.#instance.subscribe(({ loading, animating }) => {
32
+ this.#loading.set(loading)
33
+ this.#animating.set(animating)
34
+ })
35
+ }
36
+
37
+ complete(options) {
38
+ return this.#instance.complete(options)
39
+ }
40
+
41
+ cancel() {
42
+ this.#instance.cancel()
43
+ }
44
+
45
+ ngOnDestroy() {
46
+ this.#instance.cancel()
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Service for AI provider and model configuration. One config store per application.
52
+ *
53
+ * Usage:
54
+ * private providerConfig = inject(ProviderConfigService)
55
+ *
56
+ * this.providerConfig.setProvider('openai')
57
+ * const current = this.providerConfig.provider() // Signal<string>
58
+ */
59
+ @Injectable({ providedIn: 'root' })
60
+ export class ProviderConfigService {
61
+ #config = createProviderConfig()
62
+ #provider = signal(this.#config.provider)
63
+ #models = signal(this.#config.models)
64
+ #mode = signal(this.#config.mode)
65
+
66
+ provider = computed(() => this.#provider())
67
+ models = computed(() => this.#models())
68
+ mode = computed(() => this.#mode())
69
+ providers = this.#config.providers
70
+
71
+ constructor() {
72
+ this.#config.subscribe(() => {
73
+ this.#provider.set(this.#config.provider)
74
+ this.#models.set(this.#config.models)
75
+ this.#mode.set(this.#config.mode)
76
+ })
77
+ }
78
+
79
+ setProvider(id) { this.#config.setProvider(id) }
80
+ setModel(pid, mid) { this.#config.setModel(pid, mid) }
81
+ setMode(v) { this.#config.setMode(v) }
82
+ setKey(pid, v) { return this.#config.setKey(pid, v) }
83
+ getKey(pid) { return this.#config.getKey(pid) }
84
+ getModel(pid) { return this.#config.getModel(pid) }
85
+ getLabel(pid) { return this.#config.getLabel(pid) }
86
+ }
87
+
88
+ /**
89
+ * Standalone provider function. Use in bootstrapApplication() or as a
90
+ * component-level provider when you need scoped completion instances.
91
+ *
92
+ * App-level (shared singleton):
93
+ * bootstrapApplication(AppComponent, {
94
+ * providers: [provideHalohalo()]
95
+ * })
96
+ *
97
+ * Component-level (scoped instance):
98
+ * @Component({ providers: [CompletionService] })
99
+ */
100
+ export function provideHalohalo() {
101
+ return [ProviderConfigService, CompletionService]
102
+ }
@@ -1,5 +1,9 @@
1
1
  import { DEFAULT_MODELS, DEFAULT_PROVIDERS, DEFAULT_PROVIDER_LABELS } from './providers.js'
2
- import { getAdapter } from '@ulam/sawsawan'
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 = getAdapter().readPref(providerKey) || providerList[0]?.id || 'anthropic'
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
- getAdapter().readPref(`${modelPrefix}${p.id}`) || p.defaultModel || DEFAULT_MODELS[p.id] || '',
28
+ adapter.readPref(`${modelPrefix}${p.id}`) || p.defaultModel || DEFAULT_MODELS[p.id] || '',
24
29
  ])
25
30
  )
26
31
 
27
- let mode = modeKey ? getAdapter().readPref(modeKey) === 'true' : false
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
- getAdapter().writePref(providerKey, id)
44
+ adapter.writePref(providerKey, id)
40
45
  provider = id
41
46
  notify()
42
47
  },
43
48
 
44
49
  setModel(providerId, modelId) {
45
- getAdapter().writePref(`${modelPrefix}${providerId}`, modelId)
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
- getAdapter().writePref(modeKey, value ? 'true' : 'false')
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 getAdapter().setKey(`${keyPrefix}${providerId}`, value)
63
+ await adapter.setKey(`${keyPrefix}${providerId}`, value)
59
64
  },
60
65
 
61
66
  async getKey(providerId) {
62
- return (await getAdapter().getKey(`${keyPrefix}${providerId}`)) || ''
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,5 +1,7 @@
1
- import { getAdapter } from '@ulam/sawsawan'
2
1
  import { LS_AI_MODEL_PREFIX, LS_APIKEY_PREFIX, DEFAULT_AI_MODELS } from './constants.js'
2
+ import { getAdapter } from '@ulam/sawsawan'
3
+
4
+ const _adapter = getAdapter()
3
5
 
4
6
  export const PROVIDERS = [
5
7
  { id: 'anthropic', label: 'Anthropic (Claude)', placeholderKey: 'settings.api_placeholder_anthropic' },
@@ -27,12 +29,12 @@ export const PROVIDER_MODELS = {
27
29
 
28
30
  export function initModels() {
29
31
  return Object.fromEntries(
30
- PROVIDERS.map(p => [p.id, getAdapter().readPref(`${LS_AI_MODEL_PREFIX}${p.id}`) || DEFAULT_AI_MODELS[p.id] || ''])
32
+ PROVIDERS.map(p => [p.id, _adapter.readPref(`${LS_AI_MODEL_PREFIX}${p.id}`) || DEFAULT_AI_MODELS[p.id] || ''])
31
33
  )
32
34
  }
33
35
 
34
36
  export function initApiKeys() {
35
37
  return Object.fromEntries(
36
- PROVIDERS.map(p => [p.id, window.electronAPI ? '' : getAdapter().readPref(`${LS_APIKEY_PREFIX}${p.id}`) || ''])
38
+ PROVIDERS.map(p => [p.id, window.electronAPI ? '' : _adapter.readPref(`${LS_APIKEY_PREFIX}${p.id}`) || ''])
37
39
  )
38
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulam/halohalo",
3
- "version": "0.3.0",
3
+ "version": "0.3.3",
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 },
@@ -1,16 +1,21 @@
1
- import { useState, useCallback, useEffect } from 'react'
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
- const [, rerender] = useState(0)
7
- useEffect(() => config.subscribe(() => rerender(n => n + 1)), [config])
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
- provider: config.provider,
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]),
package/vue.js CHANGED
@@ -1,73 +1,73 @@
1
- /**
2
- * @ulam/halohalo/vue: Vue composables adapter
3
- */
4
- import { ref, readonly } from 'vue'
5
- import { createCompletion } from './createCompletion.js'
6
- import { createProviderConfig } from './createProviderConfig.js'
7
-
8
- /**
9
- * Reactive wrapper around createCompletion(). Returns reactive loading and
10
- * animating state, plus complete() and cancel() functions.
11
- *
12
- * @param {object} [defaultOptions] - default options merged into every complete() call
13
- */
14
- export function useCompletion(defaultOptions = {}) {
15
- const instance = createCompletion()
16
- const loading = ref(false)
17
- const animating = ref(false)
18
-
19
- const unsubscribe = instance.subscribe(({ loading: l, animating: a }) => {
20
- loading.value = l
21
- animating.value = a
22
- })
23
-
24
- // caller should invoke cleanup() in onUnmounted if used inside a component
25
- function cleanup() {
26
- instance.cancel()
27
- unsubscribe()
28
- }
29
-
30
- return {
31
- loading: readonly(loading),
32
- animating: readonly(animating),
33
- complete: (callOptions) => instance.complete({ ...defaultOptions, ...callOptions }),
34
- cancel: () => instance.cancel(),
35
- cleanup,
36
- }
37
- }
38
-
39
- /**
40
- * Reactive wrapper around createProviderConfig(). All config fields are reactive
41
- * refs; setters update the underlying singleton and trigger re-reads.
42
- *
43
- * @param {object} [storageKeys]
44
- * @param {Array} [providers]
45
- */
46
- export function useProviderConfig(storageKeys, providers) {
47
- const config = createProviderConfig(storageKeys, providers)
48
-
49
- const provider = ref(config.provider)
50
- const models = ref(config.models)
51
- const mode = ref(config.mode)
52
-
53
- const unsubscribe = config.subscribe(() => {
54
- provider.value = config.provider
55
- models.value = config.models
56
- mode.value = config.mode
57
- })
58
-
59
- return {
60
- provider: readonly(provider),
61
- models: readonly(models),
62
- mode: readonly(mode),
63
- providers: config.providers,
64
- setProvider: (id) => config.setProvider(id),
65
- setModel: (pid, mid) => config.setModel(pid, mid),
66
- setMode: (v) => config.setMode(v),
67
- setKey: (pid, v) => config.setKey(pid, v),
68
- getKey: (pid) => config.getKey(pid),
69
- getModel: (pid) => config.getModel(pid),
70
- getLabel: (pid) => config.getLabel(pid),
71
- cleanup: unsubscribe,
72
- }
73
- }
1
+ /**
2
+ * @ulam/halohalo/vue: Vue composables adapter
3
+ */
4
+ import { ref, readonly } from 'vue'
5
+ import { createCompletion } from './createCompletion.js'
6
+ import { createProviderConfig } from './createProviderConfig.js'
7
+
8
+ /**
9
+ * Reactive wrapper around createCompletion(). Returns reactive loading and
10
+ * animating state, plus complete() and cancel() functions.
11
+ *
12
+ * @param {object} [defaultOptions] - default options merged into every complete() call
13
+ */
14
+ export function useCompletion(defaultOptions = {}) {
15
+ const instance = createCompletion()
16
+ const loading = ref(false)
17
+ const animating = ref(false)
18
+
19
+ const unsubscribe = instance.subscribe(({ loading: l, animating: a }) => {
20
+ loading.value = l
21
+ animating.value = a
22
+ })
23
+
24
+ // caller should invoke cleanup() in onUnmounted if used inside a component
25
+ function cleanup() {
26
+ instance.cancel()
27
+ unsubscribe()
28
+ }
29
+
30
+ return {
31
+ loading: readonly(loading),
32
+ animating: readonly(animating),
33
+ complete: (callOptions) => instance.complete({ ...defaultOptions, ...callOptions }),
34
+ cancel: () => instance.cancel(),
35
+ cleanup,
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Reactive wrapper around createProviderConfig(). All config fields are reactive
41
+ * refs; setters update the underlying singleton and trigger re-reads.
42
+ *
43
+ * @param {object} [storageKeys]
44
+ * @param {Array} [providers]
45
+ */
46
+ export function useProviderConfig(storageKeys, providers) {
47
+ const config = createProviderConfig(storageKeys, providers)
48
+
49
+ const provider = ref(config.provider)
50
+ const models = ref(config.models)
51
+ const mode = ref(config.mode)
52
+
53
+ const unsubscribe = config.subscribe(() => {
54
+ provider.value = config.provider
55
+ models.value = config.models
56
+ mode.value = config.mode
57
+ })
58
+
59
+ return {
60
+ provider: readonly(provider),
61
+ models: readonly(models),
62
+ mode: readonly(mode),
63
+ providers: config.providers,
64
+ setProvider: (id) => config.setProvider(id),
65
+ setModel: (pid, mid) => config.setModel(pid, mid),
66
+ setMode: (v) => config.setMode(v),
67
+ setKey: (pid, v) => config.setKey(pid, v),
68
+ getKey: (pid) => config.getKey(pid),
69
+ getModel: (pid) => config.getModel(pid),
70
+ getLabel: (pid) => config.getLabel(pid),
71
+ cleanup: unsubscribe,
72
+ }
73
+ }