Repository navigation
Expand file tree
/
Copy pathconfig.ts
More file actions
242 lines (201 loc) · 8.06 KB
/
Copy pathconfig.ts
File metadata and controls
242 lines (201 loc) · 8.06 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
/**
* LLM Configuration Utilities
* Environment variable resolution and default configuration
*/
import { type LLMConfig, type LLMModelEffort, type LLMProviderType, LLM_MODEL_EFFORTS, LLMConfigError } from "../types";
// ============================================================================
// Default Configuration
// ============================================================================
const DEFAULT_PROVIDER: LLMProviderType = "gemini";
export const DEFAULT_MODELS: Record<LLMProviderType, string> = {
gemini: "gemini-2.5-flash",
openai: "gpt-4o",
ollama: "llama3.2",
custom: "gpt-3.5-turbo",
};
const VALID_PROVIDERS = Object.keys(DEFAULT_MODELS) as LLMProviderType[];
export const DEFAULT_API_URLS: Record<string, string> = {
ollama: "http://localhost:11434/v1",
openai: "https://api.openai.com/v1",
};
// ============================================================================
// Environment Resolution
// ============================================================================
/**
* Whether the operator named a provider at all. A misspelling still counts: they meant to use AI,
* and `resolveProvider` silently falls back, so the key error is the only thing left to tell them.
*/
function isProviderNamed(): boolean {
return (process.env.LLM_PROVIDER ?? "").trim() !== "";
}
function resolveProvider(): LLMProviderType {
const provider = process.env.LLM_PROVIDER?.toLowerCase();
if (!provider) {
return DEFAULT_PROVIDER;
}
if (!VALID_PROVIDERS.includes(provider as LLMProviderType)) {
console.error(`[LLM] Invalid provider "${provider}", falling back to "${DEFAULT_PROVIDER}"`);
return DEFAULT_PROVIDER;
}
return provider as LLMProviderType;
}
function resolveApiKey(provider: LLMProviderType): string | undefined {
const apiKey = process.env.LLM_API_KEY;
// Ollama doesn't require API key
if (!apiKey && provider === "ollama") {
return undefined;
}
return apiKey;
}
function resolveModel(provider: LLMProviderType): string {
const model = process.env.LLM_MODEL;
return model || DEFAULT_MODELS[provider];
}
function resolveApiUrl(provider: LLMProviderType): string | undefined {
// Primary: LLM_API_URL
const apiUrl = process.env.LLM_API_URL;
if (apiUrl) {
return apiUrl;
}
// Default URLs for specific providers
if (provider === "ollama") {
return DEFAULT_API_URLS.ollama;
}
if (provider === "openai") {
return DEFAULT_API_URLS.openai;
}
// Custom provider requires explicit URL
if (provider === "custom") {
return undefined;
}
return undefined;
}
/** `LLM_MODEL_EFFORT`, trimmed and lower-cased; unset or blank leaves the provider's default. */
function resolveModelEffort(): string | undefined {
const effort = (process.env.LLM_MODEL_EFFORT ?? "").trim().toLowerCase();
return effort === "" ? undefined : effort;
}
// ============================================================================
// Configuration Resolution
// ============================================================================
/**
* Resolve LLM configuration from environment variables with optional overrides
*/
export function resolveConfig(overrides?: Partial<LLMConfig>): LLMConfig {
const provider = overrides?.provider ?? resolveProvider();
const modelEffort = overrides?.modelEffort ?? resolveModelEffort();
return {
provider,
providerExplicit: overrides?.provider !== undefined || isProviderNamed(),
apiKey: overrides?.apiKey ?? resolveApiKey(provider),
model: overrides?.model ?? resolveModel(provider),
apiUrl: overrides?.apiUrl ?? resolveApiUrl(provider),
// Absent rather than undefined when unset, so a configuration without it is the same object
// it was before the variable existed.
...(modelEffort === undefined ? {} : { modelEffort }),
};
}
// ============================================================================
// Configuration Validation
// ============================================================================
/**
* Why credentials are absent: switched off, or a setup someone started and did not finish. Only the
* first is silent to the caller, so a named provider never hides its own missing key.
*/
export function unconfiguredReason(config: LLMConfig): "missing_credentials" | undefined {
return config.providerExplicit ? undefined : "missing_credentials";
}
/**
* The configured reasoning effort, narrowed, or undefined when none is configured.
*
* OpenAI only, because it is the one provider whose every surface sends the value: its chat
* surface as `reasoning_effort`, its agent surface as the Responses API's `reasoning.effort`.
* Gemini, Ollama and a custom endpoint have no such field on either surface, so accepting the
* variable for them would be a setting that silently does nothing.
*
* Exported separately from `validateConfig` because the Ollama and custom chat providers replace
* the base validation with their own and must still refuse it.
*
* @throws LLMConfigError for a value outside `LLM_MODEL_EFFORTS`, or any value with a provider
* other than openai
*/
export function validModelEffort(config: LLMConfig): LLMModelEffort | undefined {
const effort = config.modelEffort;
if (effort === undefined) return undefined;
if (!isModelEffort(effort)) throw new LLMConfigError(unknownEffortMessage(effort), config.provider);
if (config.provider !== "openai") throw new LLMConfigError(effortProviderMessage(config.provider), config.provider);
return effort;
}
function isModelEffort(value: string): value is LLMModelEffort {
return (LLM_MODEL_EFFORTS as readonly string[]).includes(value);
}
// Built on one line each: bun's line coverage under-counts the continuation lines of multi-line
// string concatenation.
const unknownEffortMessage = (effort: string): string =>
`Unknown LLM_MODEL_EFFORT "${effort}". Allowed values: ${LLM_MODEL_EFFORTS.join(", ")}.`;
const effortProviderMessage = (provider: string): string =>
`LLM_MODEL_EFFORT applies to LLM_PROVIDER=openai only; unset it for the ${provider} provider.`;
/**
* Validate LLM configuration
* @throws LLMConfigError if configuration is invalid
*/
export function validateConfig(config: LLMConfig): void {
// Validate provider
if (!VALID_PROVIDERS.includes(config.provider)) {
throw new LLMConfigError(
`Invalid provider: ${config.provider}. Valid options: ${VALID_PROVIDERS.join(", ")}`,
config.provider,
);
}
// Validate API key requirements
if (config.provider === "gemini" && !config.apiKey) {
throw new LLMConfigError(
"Gemini API key is required. Set LLM_API_KEY environment variable.",
"gemini",
unconfiguredReason(config),
);
}
if (config.provider === "openai" && !config.apiKey) {
throw new LLMConfigError(
"OpenAI API key is required. Set LLM_API_KEY environment variable.",
"openai",
unconfiguredReason(config),
);
}
// Validate API URL for custom provider
if (config.provider === "custom" && !config.apiUrl) {
throw new LLMConfigError("Custom provider requires LLM_API_URL environment variable.", "custom");
}
// Validate model
if (!config.model || config.model.trim() === "") {
throw new LLMConfigError(`Model name is required for ${config.provider} provider.`, config.provider);
}
validModelEffort(config);
}
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Check if a provider requires an API key
*/
export function requiresApiKey(provider: LLMProviderType): boolean {
return provider === "gemini" || provider === "openai";
}
/**
* Check if a provider requires a custom API URL
*/
export function requiresApiUrl(provider: LLMProviderType): boolean {
return provider === "custom";
}
/**
* Get safe config for logging (API key masked)
*/
export function getSafeConfigForLogging(config: LLMConfig): Record<string, string | undefined> {
return {
provider: config.provider,
model: config.model,
apiUrl: config.apiUrl,
apiKey: config.apiKey ? "***" : undefined,
modelEffort: config.modelEffort,
};
}