@open-mercato/ai-assistant 0.4.11-develop.2226.2963a4b52d → 0.4.11-develop.2231.cfcd603204
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 +69 -9
- package/dist/modules/ai_assistant/api/route/route.js +32 -44
- package/dist/modules/ai_assistant/api/route/route.js.map +2 -2
- package/dist/modules/ai_assistant/lib/ai-sdk.js +1 -0
- package/dist/modules/ai_assistant/lib/ai-sdk.js.map +2 -2
- package/dist/modules/ai_assistant/lib/chat-config.js +52 -40
- package/dist/modules/ai_assistant/lib/chat-config.js.map +2 -2
- package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +65 -0
- package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +7 -0
- package/dist/modules/ai_assistant/lib/llm-adapters/google.js +59 -0
- package/dist/modules/ai_assistant/lib/llm-adapters/google.js.map +7 -0
- package/dist/modules/ai_assistant/lib/llm-adapters/openai.js +65 -0
- package/dist/modules/ai_assistant/lib/llm-adapters/openai.js.map +7 -0
- package/dist/modules/ai_assistant/lib/llm-bootstrap.js +47 -0
- package/dist/modules/ai_assistant/lib/llm-bootstrap.js.map +7 -0
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +203 -0
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +7 -0
- package/jest.config.cjs +1 -0
- package/package.json +4 -4
- package/src/modules/ai_assistant/api/route/route.ts +49 -46
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +72 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-google.test.ts +71 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +160 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-bootstrap.test.ts +81 -0
- package/src/modules/ai_assistant/lib/ai-sdk.ts +10 -0
- package/src/modules/ai_assistant/lib/chat-config.ts +75 -42
- package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +91 -0
- package/src/modules/ai_assistant/lib/llm-adapters/google.ts +80 -0
- package/src/modules/ai_assistant/lib/llm-adapters/openai.ts +145 -0
- package/src/modules/ai_assistant/lib/llm-bootstrap.ts +80 -0
- package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +262 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createGoogleAdapter } from '../llm-adapters/google'
|
|
2
|
+
|
|
3
|
+
describe('GoogleAdapter', () => {
|
|
4
|
+
const adapter = createGoogleAdapter()
|
|
5
|
+
|
|
6
|
+
it('has expected id, name, envKeys and defaultModel', () => {
|
|
7
|
+
expect(adapter.id).toBe('google')
|
|
8
|
+
expect(adapter.name).toBe('Google')
|
|
9
|
+
expect(adapter.envKeys).toEqual(['GOOGLE_GENERATIVE_AI_API_KEY', 'OPENCODE_GOOGLE_API_KEY'])
|
|
10
|
+
expect(adapter.defaultModel).toBe('gemini-3-flash')
|
|
11
|
+
expect(adapter.defaultModels.length).toBeGreaterThan(0)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('detects configuration from env', () => {
|
|
15
|
+
expect(
|
|
16
|
+
adapter.isConfigured({ GOOGLE_GENERATIVE_AI_API_KEY: 'AIza-key' }),
|
|
17
|
+
).toBe(true)
|
|
18
|
+
expect(adapter.isConfigured({})).toBe(false)
|
|
19
|
+
expect(
|
|
20
|
+
adapter.isConfigured({ GOOGLE_GENERATIVE_AI_API_KEY: ' ' }),
|
|
21
|
+
).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('detects configuration from OPENCODE_* fallback env', () => {
|
|
25
|
+
expect(
|
|
26
|
+
adapter.isConfigured({ OPENCODE_GOOGLE_API_KEY: 'AIza-key' }),
|
|
27
|
+
).toBe(true)
|
|
28
|
+
expect(
|
|
29
|
+
adapter.isConfigured({ OPENCODE_GOOGLE_API_KEY: '' }),
|
|
30
|
+
).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('resolves API key from env', () => {
|
|
34
|
+
expect(
|
|
35
|
+
adapter.resolveApiKey({ GOOGLE_GENERATIVE_AI_API_KEY: 'AIza-key' }),
|
|
36
|
+
).toBe('AIza-key')
|
|
37
|
+
expect(adapter.resolveApiKey({})).toBeNull()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('resolves API key from OPENCODE_* fallback env', () => {
|
|
41
|
+
expect(
|
|
42
|
+
adapter.resolveApiKey({ OPENCODE_GOOGLE_API_KEY: 'opencode-key' }),
|
|
43
|
+
).toBe('opencode-key')
|
|
44
|
+
expect(
|
|
45
|
+
adapter.resolveApiKey({
|
|
46
|
+
GOOGLE_GENERATIVE_AI_API_KEY: 'primary',
|
|
47
|
+
OPENCODE_GOOGLE_API_KEY: 'fallback',
|
|
48
|
+
}),
|
|
49
|
+
).toBe('primary')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('returns the configured env key name for diagnostics', () => {
|
|
53
|
+
expect(
|
|
54
|
+
adapter.getConfiguredEnvKey({
|
|
55
|
+
GOOGLE_GENERATIVE_AI_API_KEY: 'AIza',
|
|
56
|
+
}),
|
|
57
|
+
).toBe('GOOGLE_GENERATIVE_AI_API_KEY')
|
|
58
|
+
expect(adapter.getConfiguredEnvKey({})).toBe(
|
|
59
|
+
'GOOGLE_GENERATIVE_AI_API_KEY',
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('createModel returns a non-null AI SDK model instance', () => {
|
|
64
|
+
const model = adapter.createModel({
|
|
65
|
+
apiKey: 'AIza-test',
|
|
66
|
+
modelId: 'gemini-3-flash',
|
|
67
|
+
})
|
|
68
|
+
expect(model).toBeDefined()
|
|
69
|
+
expect(model).not.toBeNull()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createOpenAICompatibleProvider,
|
|
3
|
+
type OpenAICompatiblePreset,
|
|
4
|
+
} from '../llm-adapters/openai'
|
|
5
|
+
import { OPENAI_COMPATIBLE_PRESETS } from '../openai-compatible-presets'
|
|
6
|
+
|
|
7
|
+
const DEEPINFRA_PRESET: OpenAICompatiblePreset = {
|
|
8
|
+
id: 'deepinfra',
|
|
9
|
+
name: 'DeepInfra',
|
|
10
|
+
baseURL: 'https://api.deepinfra.com/v1/openai',
|
|
11
|
+
envKeys: ['DEEPINFRA_API_KEY'],
|
|
12
|
+
defaultModel: 'zai-org/GLM-5.1',
|
|
13
|
+
defaultModels: [
|
|
14
|
+
{
|
|
15
|
+
id: 'zai-org/GLM-5.1',
|
|
16
|
+
name: 'GLM-5.1',
|
|
17
|
+
contextWindow: 202752,
|
|
18
|
+
tags: ['flagship'],
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('OpenAIAdapter (OpenAI-compatible provider factory)', () => {
|
|
24
|
+
it('validates preset shape at construction time', () => {
|
|
25
|
+
expect(() =>
|
|
26
|
+
createOpenAICompatibleProvider({
|
|
27
|
+
...DEEPINFRA_PRESET,
|
|
28
|
+
id: '',
|
|
29
|
+
}),
|
|
30
|
+
).toThrow('must have a non-empty id')
|
|
31
|
+
|
|
32
|
+
expect(() =>
|
|
33
|
+
createOpenAICompatibleProvider({
|
|
34
|
+
...DEEPINFRA_PRESET,
|
|
35
|
+
envKeys: [],
|
|
36
|
+
}),
|
|
37
|
+
).toThrow('must declare at least one env key')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('exposes preset metadata through the LlmProvider port', () => {
|
|
41
|
+
const provider = createOpenAICompatibleProvider(DEEPINFRA_PRESET)
|
|
42
|
+
expect(provider.id).toBe('deepinfra')
|
|
43
|
+
expect(provider.name).toBe('DeepInfra')
|
|
44
|
+
expect(provider.envKeys).toEqual(['DEEPINFRA_API_KEY'])
|
|
45
|
+
expect(provider.defaultModel).toBe('zai-org/GLM-5.1')
|
|
46
|
+
expect(provider.defaultModels[0].id).toBe('zai-org/GLM-5.1')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('detects configuration via the preset-specific env key', () => {
|
|
50
|
+
const provider = createOpenAICompatibleProvider(DEEPINFRA_PRESET)
|
|
51
|
+
expect(provider.isConfigured({ DEEPINFRA_API_KEY: 'key' })).toBe(true)
|
|
52
|
+
// Unrelated env key does NOT configure the preset.
|
|
53
|
+
expect(provider.isConfigured({ OPENAI_API_KEY: 'unrelated' })).toBe(
|
|
54
|
+
false,
|
|
55
|
+
)
|
|
56
|
+
expect(provider.isConfigured({})).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('resolves API key and env key name for diagnostics', () => {
|
|
60
|
+
const provider = createOpenAICompatibleProvider(DEEPINFRA_PRESET)
|
|
61
|
+
expect(
|
|
62
|
+
provider.resolveApiKey({ DEEPINFRA_API_KEY: 'secret' }),
|
|
63
|
+
).toBe('secret')
|
|
64
|
+
expect(
|
|
65
|
+
provider.getConfiguredEnvKey({ DEEPINFRA_API_KEY: 'secret' }),
|
|
66
|
+
).toBe('DEEPINFRA_API_KEY')
|
|
67
|
+
expect(provider.getConfiguredEnvKey({})).toBe('DEEPINFRA_API_KEY')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('createModel returns a non-null AI SDK model instance', () => {
|
|
71
|
+
const provider = createOpenAICompatibleProvider(DEEPINFRA_PRESET)
|
|
72
|
+
const model = provider.createModel({
|
|
73
|
+
apiKey: 'test-key',
|
|
74
|
+
modelId: 'zai-org/GLM-5.1',
|
|
75
|
+
})
|
|
76
|
+
expect(model).toBeDefined()
|
|
77
|
+
expect(model).not.toBeNull()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('createModel honors per-call baseURL override', () => {
|
|
81
|
+
const provider = createOpenAICompatibleProvider(DEEPINFRA_PRESET)
|
|
82
|
+
// AI SDK does not crash when baseURL is overridden per-call.
|
|
83
|
+
const model = provider.createModel({
|
|
84
|
+
apiKey: 'test-key',
|
|
85
|
+
modelId: 'zai-org/GLM-5.1',
|
|
86
|
+
baseURL: 'https://example.com/v1',
|
|
87
|
+
})
|
|
88
|
+
expect(model).toBeDefined()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('azure preset honors baseURLEnvKeys override', () => {
|
|
92
|
+
const azurePreset = OPENAI_COMPATIBLE_PRESETS.find(
|
|
93
|
+
(p) => p.id === 'azure',
|
|
94
|
+
)
|
|
95
|
+
expect(azurePreset).toBeDefined()
|
|
96
|
+
expect(azurePreset?.baseURLEnvKeys).toContain('AZURE_OPENAI_BASE_URL')
|
|
97
|
+
// Sanity: the adapter can be created from the preset.
|
|
98
|
+
const provider = createOpenAICompatibleProvider(azurePreset!)
|
|
99
|
+
expect(provider.id).toBe('azure')
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('OpenAI preset OPENCODE_* fallback env keys', () => {
|
|
104
|
+
it('openai preset includes OPENCODE_OPENAI_API_KEY fallback', () => {
|
|
105
|
+
const openaiPreset = OPENAI_COMPATIBLE_PRESETS.find((p) => p.id === 'openai')
|
|
106
|
+
expect(openaiPreset).toBeDefined()
|
|
107
|
+
expect(openaiPreset!.envKeys).toContain('OPENAI_API_KEY')
|
|
108
|
+
expect(openaiPreset!.envKeys).toContain('OPENCODE_OPENAI_API_KEY')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('resolves API key from OPENCODE_OPENAI_API_KEY fallback', () => {
|
|
112
|
+
const openaiPreset = OPENAI_COMPATIBLE_PRESETS.find((p) => p.id === 'openai')
|
|
113
|
+
const provider = createOpenAICompatibleProvider(openaiPreset!)
|
|
114
|
+
expect(
|
|
115
|
+
provider.resolveApiKey({ OPENCODE_OPENAI_API_KEY: 'opencode-key' }),
|
|
116
|
+
).toBe('opencode-key')
|
|
117
|
+
expect(
|
|
118
|
+
provider.resolveApiKey({
|
|
119
|
+
OPENAI_API_KEY: 'primary',
|
|
120
|
+
OPENCODE_OPENAI_API_KEY: 'fallback',
|
|
121
|
+
}),
|
|
122
|
+
).toBe('primary')
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('OPENAI_COMPATIBLE_PRESETS built-in catalog', () => {
|
|
127
|
+
it('ships at least 8 built-in presets including openai and deepinfra', () => {
|
|
128
|
+
expect(OPENAI_COMPATIBLE_PRESETS.length).toBeGreaterThanOrEqual(8)
|
|
129
|
+
const ids = OPENAI_COMPATIBLE_PRESETS.map((p) => p.id)
|
|
130
|
+
expect(ids).toContain('openai')
|
|
131
|
+
expect(ids).toContain('deepinfra')
|
|
132
|
+
expect(ids).toContain('groq')
|
|
133
|
+
expect(ids).toContain('together')
|
|
134
|
+
expect(ids).toContain('fireworks')
|
|
135
|
+
expect(ids).toContain('azure')
|
|
136
|
+
expect(ids).toContain('litellm')
|
|
137
|
+
expect(ids).toContain('ollama')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('every preset has at least one model and one env key', () => {
|
|
141
|
+
for (const preset of OPENAI_COMPATIBLE_PRESETS) {
|
|
142
|
+
expect(preset.envKeys.length).toBeGreaterThan(0)
|
|
143
|
+
expect(preset.defaultModels.length).toBeGreaterThan(0)
|
|
144
|
+
expect(preset.defaultModel.length).toBeGreaterThan(0)
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('every preset defaultModel exists in its defaultModels array', () => {
|
|
149
|
+
for (const preset of OPENAI_COMPATIBLE_PRESETS) {
|
|
150
|
+
const ids = preset.defaultModels.map((m) => m.id)
|
|
151
|
+
expect(ids).toContain(preset.defaultModel)
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('every preset id is unique', () => {
|
|
156
|
+
const ids = OPENAI_COMPATIBLE_PRESETS.map((p) => p.id)
|
|
157
|
+
const unique = new Set(ids)
|
|
158
|
+
expect(unique.size).toBe(ids.length)
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'
|
|
2
|
+
import {
|
|
3
|
+
registerBuiltInLlmProviders,
|
|
4
|
+
resetLlmBootstrapState,
|
|
5
|
+
} from '../llm-bootstrap'
|
|
6
|
+
|
|
7
|
+
describe('llm-bootstrap — built-in provider registration', () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
llmProviderRegistry.reset()
|
|
10
|
+
resetLlmBootstrapState()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('registers the three native providers and all OpenAI-compatible presets', () => {
|
|
14
|
+
registerBuiltInLlmProviders()
|
|
15
|
+
const ids = llmProviderRegistry.list().map((p) => p.id)
|
|
16
|
+
|
|
17
|
+
// Native protocol adapters.
|
|
18
|
+
expect(ids).toContain('anthropic')
|
|
19
|
+
expect(ids).toContain('google')
|
|
20
|
+
|
|
21
|
+
// OpenAI-compatible presets.
|
|
22
|
+
expect(ids).toContain('openai')
|
|
23
|
+
expect(ids).toContain('deepinfra')
|
|
24
|
+
expect(ids).toContain('groq')
|
|
25
|
+
expect(ids).toContain('together')
|
|
26
|
+
expect(ids).toContain('fireworks')
|
|
27
|
+
expect(ids).toContain('azure')
|
|
28
|
+
expect(ids).toContain('litellm')
|
|
29
|
+
expect(ids).toContain('ollama')
|
|
30
|
+
|
|
31
|
+
expect(ids.length).toBeGreaterThanOrEqual(10)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('is idempotent — calling twice does not duplicate entries', () => {
|
|
35
|
+
registerBuiltInLlmProviders()
|
|
36
|
+
const afterFirst = llmProviderRegistry.list().length
|
|
37
|
+
registerBuiltInLlmProviders()
|
|
38
|
+
const afterSecond = llmProviderRegistry.list().length
|
|
39
|
+
expect(afterSecond).toBe(afterFirst)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('re-registers after registry reset when bootstrap state is reset', () => {
|
|
43
|
+
registerBuiltInLlmProviders()
|
|
44
|
+
expect(llmProviderRegistry.list().length).toBeGreaterThan(0)
|
|
45
|
+
|
|
46
|
+
llmProviderRegistry.reset()
|
|
47
|
+
resetLlmBootstrapState()
|
|
48
|
+
expect(llmProviderRegistry.list().length).toBe(0)
|
|
49
|
+
|
|
50
|
+
registerBuiltInLlmProviders()
|
|
51
|
+
expect(llmProviderRegistry.list().length).toBeGreaterThan(0)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('anthropic provider comes from the AnthropicAdapter factory', () => {
|
|
55
|
+
registerBuiltInLlmProviders()
|
|
56
|
+
const anthropic = llmProviderRegistry.get('anthropic')
|
|
57
|
+
expect(anthropic).not.toBeNull()
|
|
58
|
+
expect(anthropic?.name).toBe('Anthropic')
|
|
59
|
+
expect(anthropic?.envKeys).toEqual(['ANTHROPIC_API_KEY', 'OPENCODE_ANTHROPIC_API_KEY'])
|
|
60
|
+
expect(anthropic?.defaultModel).toBe('claude-haiku-4-5-20251001')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('deepinfra provider comes from the OpenAI-compatible preset', () => {
|
|
64
|
+
registerBuiltInLlmProviders()
|
|
65
|
+
const deepinfra = llmProviderRegistry.get('deepinfra')
|
|
66
|
+
expect(deepinfra).not.toBeNull()
|
|
67
|
+
expect(deepinfra?.envKeys).toEqual(['DEEPINFRA_API_KEY'])
|
|
68
|
+
expect(deepinfra?.defaultModel).toBe('zai-org/GLM-5.1')
|
|
69
|
+
const modelIds = deepinfra?.defaultModels.map((m) => m.id) ?? []
|
|
70
|
+
expect(modelIds).toContain('zai-org/GLM-5.1')
|
|
71
|
+
expect(modelIds).toContain('Qwen/Qwen3-235B-A22B-Instruct-2507')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('resolveFirstConfigured picks a configured provider from the registry', () => {
|
|
75
|
+
registerBuiltInLlmProviders()
|
|
76
|
+
const picked = llmProviderRegistry.resolveFirstConfigured({
|
|
77
|
+
env: { DEEPINFRA_API_KEY: 'deepinfra-key' },
|
|
78
|
+
})
|
|
79
|
+
expect(picked?.id).toBe('deepinfra')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -3,3 +3,13 @@ export { streamText, generateObject, stepCountIs } from 'ai'
|
|
|
3
3
|
export { createOpenAI } from '@ai-sdk/openai'
|
|
4
4
|
export { createAnthropic } from '@ai-sdk/anthropic'
|
|
5
5
|
export { createGoogleGenerativeAI } from '@ai-sdk/google'
|
|
6
|
+
|
|
7
|
+
// Side-effect import: registers built-in LLM providers (Anthropic, Google,
|
|
8
|
+
// OpenAI + OpenAI-compatible presets for DeepInfra, Groq, Together, etc.)
|
|
9
|
+
// with the shared `llmProviderRegistry` singleton. Consumers that import
|
|
10
|
+
// from `./ai-sdk` transitively trigger provider bootstrap, so any module
|
|
11
|
+
// using `generateObject` / `streamText` already has the registry populated.
|
|
12
|
+
//
|
|
13
|
+
// @see ./llm-bootstrap.ts
|
|
14
|
+
// @see .ai/specs/2026-04-14-llm-provider-ports-and-adapters.md
|
|
15
|
+
import './llm-bootstrap'
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
} from '@open-mercato/shared/lib/ai/opencode-provider'
|
|
2
|
+
import { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'
|
|
3
|
+
import type { LlmProvider } from '@open-mercato/shared/lib/ai/llm-provider'
|
|
4
|
+
// Side-effect: ensures the registry is populated with built-in adapters
|
|
5
|
+
// and OpenAI-compatible presets before this module's getters run.
|
|
6
|
+
import './llm-bootstrap'
|
|
8
7
|
|
|
9
8
|
// Types
|
|
10
|
-
|
|
9
|
+
//
|
|
10
|
+
// `ChatProviderId` was previously a narrow literal union of three ids
|
|
11
|
+
// (`'anthropic' | 'openai' | 'google'`). After the ports & adapters
|
|
12
|
+
// refactor the registry accepts any stable id string, so this type
|
|
13
|
+
// becomes `string`. Backward-compatibility note: downstream callers that
|
|
14
|
+
// used exhaustive switches on the old union must add a `default:` branch.
|
|
15
|
+
// See `.ai/specs/2026-04-14-llm-provider-ports-and-adapters.md`.
|
|
16
|
+
export type ChatProviderId = string
|
|
11
17
|
|
|
12
18
|
export type ChatModelInfo = {
|
|
13
19
|
id: string
|
|
@@ -31,51 +37,74 @@ export type ChatProviderConfig = {
|
|
|
31
37
|
// Constants
|
|
32
38
|
export const CHAT_CONFIG_KEY = 'chat_provider'
|
|
33
39
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
name:
|
|
37
|
-
envKeyRequired:
|
|
38
|
-
defaultModel:
|
|
39
|
-
models:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
envKeyRequired: OPEN_CODE_PROVIDERS.anthropic.envKeys[0],
|
|
46
|
-
defaultModel: OPEN_CODE_PROVIDERS.anthropic.defaultModel,
|
|
47
|
-
models: [
|
|
48
|
-
{ id: OPEN_CODE_PROVIDERS.anthropic.defaultModel, name: 'Claude Haiku 4.5', contextWindow: 200000 },
|
|
49
|
-
],
|
|
50
|
-
},
|
|
51
|
-
google: {
|
|
52
|
-
name: OPEN_CODE_PROVIDERS.google.name,
|
|
53
|
-
envKeyRequired: OPEN_CODE_PROVIDERS.google.envKeys[0],
|
|
54
|
-
defaultModel: OPEN_CODE_PROVIDERS.google.defaultModel,
|
|
55
|
-
models: [
|
|
56
|
-
{ id: OPEN_CODE_PROVIDERS.google.defaultModel, name: 'Gemini 3 Flash', contextWindow: 1048576 },
|
|
57
|
-
],
|
|
58
|
-
},
|
|
40
|
+
function providerToChatInfo(provider: LlmProvider): ChatProviderInfo {
|
|
41
|
+
return {
|
|
42
|
+
name: provider.name,
|
|
43
|
+
envKeyRequired: provider.envKeys[0],
|
|
44
|
+
defaultModel: provider.defaultModel,
|
|
45
|
+
models: provider.defaultModels.map((m) => ({
|
|
46
|
+
id: m.id,
|
|
47
|
+
name: m.name,
|
|
48
|
+
contextWindow: m.contextWindow,
|
|
49
|
+
})),
|
|
50
|
+
}
|
|
59
51
|
}
|
|
60
52
|
|
|
53
|
+
/**
|
|
54
|
+
* `CHAT_PROVIDERS` is a dynamic getter that returns all providers
|
|
55
|
+
* registered with `llmProviderRegistry`. The shape
|
|
56
|
+
* (`Record<string, ChatProviderInfo>`) is preserved so existing code that
|
|
57
|
+
* indexed the map with a string literal (`CHAT_PROVIDERS['anthropic']`)
|
|
58
|
+
* keeps working — it is now a runtime lookup against the registry.
|
|
59
|
+
*/
|
|
60
|
+
export const CHAT_PROVIDERS: Record<string, ChatProviderInfo> = new Proxy(
|
|
61
|
+
{} as Record<string, ChatProviderInfo>,
|
|
62
|
+
{
|
|
63
|
+
get(_target, prop: string): ChatProviderInfo | undefined {
|
|
64
|
+
if (typeof prop !== 'string') return undefined
|
|
65
|
+
const provider = llmProviderRegistry.get(prop)
|
|
66
|
+
return provider ? providerToChatInfo(provider) : undefined
|
|
67
|
+
},
|
|
68
|
+
has(_target, prop: string): boolean {
|
|
69
|
+
if (typeof prop !== 'string') return false
|
|
70
|
+
return llmProviderRegistry.get(prop) !== null
|
|
71
|
+
},
|
|
72
|
+
ownKeys(): string[] {
|
|
73
|
+
return llmProviderRegistry.list().map((p) => p.id)
|
|
74
|
+
},
|
|
75
|
+
getOwnPropertyDescriptor(_target, prop: string): PropertyDescriptor | undefined {
|
|
76
|
+
if (typeof prop !== 'string') return undefined
|
|
77
|
+
const provider = llmProviderRegistry.get(prop)
|
|
78
|
+
if (!provider) return undefined
|
|
79
|
+
return {
|
|
80
|
+
enumerable: true,
|
|
81
|
+
configurable: true,
|
|
82
|
+
value: providerToChatInfo(provider),
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
|
|
61
88
|
export const DEFAULT_CHAT_CONFIG: Omit<ChatProviderConfig, 'updatedAt'> = {
|
|
62
89
|
providerId: 'openai',
|
|
63
|
-
model:
|
|
90
|
+
get model(): string {
|
|
91
|
+
// Lazy resolution so the bootstrap has a chance to register providers
|
|
92
|
+
// before the default is computed.
|
|
93
|
+
const provider = llmProviderRegistry.get('openai')
|
|
94
|
+
return provider?.defaultModel ?? 'gpt-5-mini'
|
|
95
|
+
},
|
|
64
96
|
}
|
|
65
97
|
|
|
66
98
|
// Provider configuration checks
|
|
67
99
|
export function isProviderConfigured(providerId: ChatProviderId): boolean {
|
|
68
|
-
|
|
100
|
+
const provider = llmProviderRegistry.get(providerId)
|
|
101
|
+
return provider?.isConfigured() ?? false
|
|
69
102
|
}
|
|
70
103
|
|
|
71
104
|
export function getConfiguredProviders(): ChatProviderId[] {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
providers.push(providerId)
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return providers
|
|
105
|
+
return llmProviderRegistry
|
|
106
|
+
.listConfigured()
|
|
107
|
+
.map((p) => p.id)
|
|
79
108
|
}
|
|
80
109
|
|
|
81
110
|
// Config resolution
|
|
@@ -121,7 +150,11 @@ export async function saveChatConfig(
|
|
|
121
150
|
}
|
|
122
151
|
|
|
123
152
|
export function createDefaultConfig(): ChatProviderConfig {
|
|
124
|
-
return {
|
|
153
|
+
return {
|
|
154
|
+
providerId: DEFAULT_CHAT_CONFIG.providerId,
|
|
155
|
+
model: DEFAULT_CHAT_CONFIG.model,
|
|
156
|
+
updatedAt: new Date().toISOString(),
|
|
157
|
+
}
|
|
125
158
|
}
|
|
126
159
|
|
|
127
160
|
// Get model info by ID
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnthropicAdapter — implements the LlmProvider port for Anthropic's
|
|
3
|
+
* Messages API (Claude Haiku, Sonnet, Opus).
|
|
4
|
+
*
|
|
5
|
+
* Wraps `createAnthropic({ apiKey })` from `@ai-sdk/anthropic` and exposes
|
|
6
|
+
* a curated model list for the AI Assistant UI dropdown.
|
|
7
|
+
*
|
|
8
|
+
* @see packages/shared/src/lib/ai/llm-provider.ts
|
|
9
|
+
* @see .ai/specs/2026-04-14-llm-provider-ports-and-adapters.md
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createAnthropic } from '@ai-sdk/anthropic'
|
|
13
|
+
import type {
|
|
14
|
+
EnvLookup,
|
|
15
|
+
LlmCreateModelOptions,
|
|
16
|
+
LlmModelInfo,
|
|
17
|
+
LlmProvider,
|
|
18
|
+
} from '@open-mercato/shared/lib/ai/llm-provider'
|
|
19
|
+
|
|
20
|
+
const DEFAULT_MODEL = 'claude-haiku-4-5-20251001'
|
|
21
|
+
|
|
22
|
+
const DEFAULT_MODELS: readonly LlmModelInfo[] = [
|
|
23
|
+
{
|
|
24
|
+
id: 'claude-haiku-4-5-20251001',
|
|
25
|
+
name: 'Claude Haiku 4.5',
|
|
26
|
+
contextWindow: 200000,
|
|
27
|
+
tags: ['budget'],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'claude-sonnet-4-6-20260107',
|
|
31
|
+
name: 'Claude Sonnet 4.6',
|
|
32
|
+
contextWindow: 200000,
|
|
33
|
+
tags: ['flagship'],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'claude-opus-4-6-20260107',
|
|
37
|
+
name: 'Claude Opus 4.6',
|
|
38
|
+
contextWindow: 1000000,
|
|
39
|
+
tags: ['flagship', 'reasoning'],
|
|
40
|
+
},
|
|
41
|
+
] as const
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Factory returning a fresh `AnthropicAdapter` instance. The adapter is
|
|
45
|
+
* stateless — caller is free to reuse the returned object.
|
|
46
|
+
*/
|
|
47
|
+
export function createAnthropicAdapter(): LlmProvider {
|
|
48
|
+
const envKeys = ['ANTHROPIC_API_KEY', 'OPENCODE_ANTHROPIC_API_KEY'] as const
|
|
49
|
+
|
|
50
|
+
function resolveApiKey(env?: EnvLookup): string | null {
|
|
51
|
+
const lookup = env ?? process.env
|
|
52
|
+
for (const key of envKeys) {
|
|
53
|
+
const value = lookup[key]
|
|
54
|
+
if (typeof value === 'string') {
|
|
55
|
+
const trimmed = value.trim()
|
|
56
|
+
if (trimmed.length > 0) return trimmed
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
id: 'anthropic',
|
|
64
|
+
name: 'Anthropic',
|
|
65
|
+
envKeys,
|
|
66
|
+
defaultModel: DEFAULT_MODEL,
|
|
67
|
+
defaultModels: DEFAULT_MODELS,
|
|
68
|
+
|
|
69
|
+
isConfigured(env?: EnvLookup): boolean {
|
|
70
|
+
return resolveApiKey(env) !== null
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
resolveApiKey,
|
|
74
|
+
|
|
75
|
+
getConfiguredEnvKey(env?: EnvLookup): string {
|
|
76
|
+
const lookup = env ?? process.env
|
|
77
|
+
for (const key of envKeys) {
|
|
78
|
+
const value = lookup[key]
|
|
79
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
80
|
+
return key
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return envKeys[0]
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
createModel(options: LlmCreateModelOptions): unknown {
|
|
87
|
+
const anthropic = createAnthropic({ apiKey: options.apiKey })
|
|
88
|
+
return anthropic(options.modelId)
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GoogleAdapter — implements the LlmProvider port for Google's Generative
|
|
3
|
+
* AI API (Gemini Flash, Pro).
|
|
4
|
+
*
|
|
5
|
+
* Wraps `createGoogleGenerativeAI({ apiKey })` from `@ai-sdk/google`.
|
|
6
|
+
*
|
|
7
|
+
* @see packages/shared/src/lib/ai/llm-provider.ts
|
|
8
|
+
* @see .ai/specs/2026-04-14-llm-provider-ports-and-adapters.md
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createGoogleGenerativeAI } from '@ai-sdk/google'
|
|
12
|
+
import type {
|
|
13
|
+
EnvLookup,
|
|
14
|
+
LlmCreateModelOptions,
|
|
15
|
+
LlmModelInfo,
|
|
16
|
+
LlmProvider,
|
|
17
|
+
} from '@open-mercato/shared/lib/ai/llm-provider'
|
|
18
|
+
|
|
19
|
+
const DEFAULT_MODEL = 'gemini-3-flash'
|
|
20
|
+
|
|
21
|
+
const DEFAULT_MODELS: readonly LlmModelInfo[] = [
|
|
22
|
+
{
|
|
23
|
+
id: 'gemini-3-flash',
|
|
24
|
+
name: 'Gemini 3 Flash',
|
|
25
|
+
contextWindow: 1048576,
|
|
26
|
+
tags: ['budget'],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'gemini-3-pro',
|
|
30
|
+
name: 'Gemini 3 Pro',
|
|
31
|
+
contextWindow: 1048576,
|
|
32
|
+
tags: ['flagship'],
|
|
33
|
+
},
|
|
34
|
+
] as const
|
|
35
|
+
|
|
36
|
+
export function createGoogleAdapter(): LlmProvider {
|
|
37
|
+
const envKeys = ['GOOGLE_GENERATIVE_AI_API_KEY', 'OPENCODE_GOOGLE_API_KEY'] as const
|
|
38
|
+
|
|
39
|
+
function resolveApiKey(env?: EnvLookup): string | null {
|
|
40
|
+
const lookup = env ?? process.env
|
|
41
|
+
for (const key of envKeys) {
|
|
42
|
+
const value = lookup[key]
|
|
43
|
+
if (typeof value === 'string') {
|
|
44
|
+
const trimmed = value.trim()
|
|
45
|
+
if (trimmed.length > 0) return trimmed
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
id: 'google',
|
|
53
|
+
name: 'Google',
|
|
54
|
+
envKeys,
|
|
55
|
+
defaultModel: DEFAULT_MODEL,
|
|
56
|
+
defaultModels: DEFAULT_MODELS,
|
|
57
|
+
|
|
58
|
+
isConfigured(env?: EnvLookup): boolean {
|
|
59
|
+
return resolveApiKey(env) !== null
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
resolveApiKey,
|
|
63
|
+
|
|
64
|
+
getConfiguredEnvKey(env?: EnvLookup): string {
|
|
65
|
+
const lookup = env ?? process.env
|
|
66
|
+
for (const key of envKeys) {
|
|
67
|
+
const value = lookup[key]
|
|
68
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
69
|
+
return key
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return envKeys[0]
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
createModel(options: LlmCreateModelOptions): unknown {
|
|
76
|
+
const google = createGoogleGenerativeAI({ apiKey: options.apiKey })
|
|
77
|
+
return google(options.modelId)
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|