offrouter-core 0.2.1 → 0.2.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/dist/audit.d.ts +137 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +320 -0
- package/dist/audit.js.map +1 -0
- package/dist/auth/credential-chain.d.ts +84 -0
- package/dist/auth/credential-chain.d.ts.map +1 -0
- package/dist/auth/credential-chain.js +150 -0
- package/dist/auth/credential-chain.js.map +1 -0
- package/dist/auth/index.d.ts +10 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +7 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/keychain.d.ts +60 -0
- package/dist/auth/keychain.d.ts.map +1 -0
- package/dist/auth/keychain.js +104 -0
- package/dist/auth/keychain.js.map +1 -0
- package/dist/auth/oauth-pkce.d.ts +138 -0
- package/dist/auth/oauth-pkce.d.ts.map +1 -0
- package/dist/auth/oauth-pkce.js +375 -0
- package/dist/auth/oauth-pkce.js.map +1 -0
- package/dist/auth/oauth-server.d.ts +36 -0
- package/dist/auth/oauth-server.d.ts.map +1 -0
- package/dist/auth/oauth-server.js +210 -0
- package/dist/auth/oauth-server.js.map +1 -0
- package/dist/config.d.ts +76 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +355 -0
- package/dist/config.js.map +1 -0
- package/dist/delegation.d.ts +123 -0
- package/dist/delegation.d.ts.map +1 -0
- package/dist/delegation.js +455 -0
- package/dist/delegation.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/policy.d.ts +45 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +318 -0
- package/dist/policy.js.map +1 -0
- package/{src/protocol.ts → dist/protocol.d.ts} +2 -2
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +6 -0
- package/dist/protocol.js.map +1 -0
- package/dist/providers/adapter.d.ts +137 -0
- package/dist/providers/adapter.d.ts.map +1 -0
- package/dist/providers/adapter.js +163 -0
- package/dist/providers/adapter.js.map +1 -0
- package/dist/providers/catalog-data.d.ts +128 -0
- package/dist/providers/catalog-data.d.ts.map +1 -0
- package/dist/providers/catalog-data.js +397 -0
- package/dist/providers/catalog-data.js.map +1 -0
- package/dist/providers/catalog.d.ts +63 -0
- package/dist/providers/catalog.d.ts.map +1 -0
- package/dist/providers/catalog.js +394 -0
- package/dist/providers/catalog.js.map +1 -0
- package/dist/providers/fake.d.ts +46 -0
- package/dist/providers/fake.d.ts.map +1 -0
- package/dist/providers/fake.js +234 -0
- package/dist/providers/fake.js.map +1 -0
- package/dist/providers/openai-compatible.d.ts +65 -0
- package/dist/providers/openai-compatible.d.ts.map +1 -0
- package/dist/providers/openai-compatible.js +434 -0
- package/dist/providers/openai-compatible.js.map +1 -0
- package/dist/proxy/responses-mapper.d.ts +366 -0
- package/dist/proxy/responses-mapper.d.ts.map +1 -0
- package/dist/proxy/responses-mapper.js +517 -0
- package/dist/proxy/responses-mapper.js.map +1 -0
- package/dist/proxy/responses-server.d.ts +29 -0
- package/dist/proxy/responses-server.d.ts.map +1 -0
- package/dist/proxy/responses-server.js +360 -0
- package/dist/proxy/responses-server.js.map +1 -0
- package/dist/router.d.ts +18 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +296 -0
- package/dist/router.js.map +1 -0
- package/dist/schemas.d.ts +560 -0
- package/dist/schemas.d.ts.map +1 -0
- package/{src/schemas.ts → dist/schemas.js} +83 -103
- package/dist/schemas.js.map +1 -0
- package/dist/secrets.d.ts +112 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/secrets.js +326 -0
- package/dist/secrets.js.map +1 -0
- package/dist/types.d.ts +117 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/usage-file.d.ts +59 -0
- package/dist/usage-file.d.ts.map +1 -0
- package/dist/usage-file.js +316 -0
- package/dist/usage-file.js.map +1 -0
- package/dist/usage.d.ts +235 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +517 -0
- package/dist/usage.js.map +1 -0
- package/package.json +7 -2
- package/src/audit.test.ts +0 -302
- package/src/audit.ts +0 -553
- package/src/auth/credential-chain.test.ts +0 -326
- package/src/auth/credential-chain.ts +0 -235
- package/src/auth/index.ts +0 -45
- package/src/auth/keychain.test.ts +0 -265
- package/src/auth/keychain.ts +0 -168
- package/src/auth/oauth-pkce.test.ts +0 -329
- package/src/auth/oauth-pkce.ts +0 -571
- package/src/auth/oauth-server.test.ts +0 -83
- package/src/auth/oauth-server.ts +0 -296
- package/src/config.test.ts +0 -479
- package/src/config.ts +0 -505
- package/src/delegation.test.ts +0 -368
- package/src/delegation.ts +0 -605
- package/src/index.ts +0 -280
- package/src/policy.test.ts +0 -634
- package/src/policy.ts +0 -420
- package/src/providers/adapter.test.ts +0 -293
- package/src/providers/adapter.ts +0 -328
- package/src/providers/catalog-data.test.ts +0 -258
- package/src/providers/catalog-data.ts +0 -498
- package/src/providers/catalog.test.ts +0 -84
- package/src/providers/catalog.ts +0 -483
- package/src/providers/fake.ts +0 -312
- package/src/providers/openai-compatible.test.ts +0 -366
- package/src/providers/openai-compatible.ts +0 -554
- package/src/proxy/responses-mapper.test.ts +0 -290
- package/src/proxy/responses-mapper.ts +0 -736
- package/src/proxy/responses-server.test.ts +0 -322
- package/src/proxy/responses-server.ts +0 -469
- package/src/router.test.ts +0 -699
- package/src/router.ts +0 -352
- package/src/schemas.test.ts +0 -291
- package/src/secrets.test.ts +0 -271
- package/src/secrets.ts +0 -461
- package/src/types.ts +0 -173
- package/src/usage-file.test.ts +0 -243
- package/src/usage-file.ts +0 -435
- package/src/usage.test.ts +0 -335
- package/src/usage.ts +0 -859
- package/tsconfig.json +0 -9
package/src/config.ts
DELETED
|
@@ -1,505 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OffRouter V1 user/project config loader.
|
|
3
|
-
* Loads TOML from OFFROUTER_HOME (or ~/.offrouter) with Zod validation.
|
|
4
|
-
* Project .offrouter/config.toml is ignored unless the workspace is trusted.
|
|
5
|
-
*/
|
|
6
|
-
import { readdir, readFile } from "node:fs/promises";
|
|
7
|
-
import { homedir as osHomedir } from "node:os";
|
|
8
|
-
import { join, resolve } from "node:path";
|
|
9
|
-
import { parse as parseToml } from "smol-toml";
|
|
10
|
-
import { z } from "zod";
|
|
11
|
-
import type { PolicyConfig } from "./policy.js";
|
|
12
|
-
|
|
13
|
-
/** Defaults applied when user config omits policy knobs. */
|
|
14
|
-
export interface PolicyDefaults {
|
|
15
|
-
subscriptionFirst: boolean;
|
|
16
|
-
allowApiKeyFallback: boolean;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Declared provider entry from config (no secrets here). */
|
|
20
|
-
export interface ConfiguredProvider {
|
|
21
|
-
id: string;
|
|
22
|
-
enabled: boolean;
|
|
23
|
-
accounts: ConfiguredAccount[];
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Named profile overlay loaded from profiles/*.toml. */
|
|
27
|
-
export interface ConfiguredProfile {
|
|
28
|
-
id: string;
|
|
29
|
-
deniedProviders?: string[];
|
|
30
|
-
allowApiKeyFallback?: boolean;
|
|
31
|
-
subscriptionFirst?: boolean;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Single configured account within a provider. */
|
|
35
|
-
export interface ConfiguredAccount {
|
|
36
|
-
id: string;
|
|
37
|
-
label?: string;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface ConfigWarning {
|
|
41
|
-
filePath: string;
|
|
42
|
-
path: string;
|
|
43
|
-
message: string;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface OffRouterConfig {
|
|
47
|
-
homeDir: string;
|
|
48
|
-
sources: string[];
|
|
49
|
-
policy: PolicyConfig;
|
|
50
|
-
policyDefaults: PolicyDefaults;
|
|
51
|
-
providers: Record<string, ConfiguredProvider>;
|
|
52
|
-
profiles: Record<string, ConfiguredProfile>;
|
|
53
|
-
warnings: ConfigWarning[];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface LoadConfigOptions {
|
|
57
|
-
/** Override process.env (for tests). */
|
|
58
|
-
env?: NodeJS.ProcessEnv;
|
|
59
|
-
/** Override os.homedir (for tests). */
|
|
60
|
-
homedir?: () => string;
|
|
61
|
-
/** Workspace directory that may contain .offrouter/config.toml. */
|
|
62
|
-
workspaceDir?: string;
|
|
63
|
-
/**
|
|
64
|
-
* Whether the workspace is trusted. Project config is ignored when false
|
|
65
|
-
* or when workspaceDir is unset.
|
|
66
|
-
*/
|
|
67
|
-
workspaceTrusted?: boolean;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export interface ResolveHomeOptions {
|
|
71
|
-
env?: NodeJS.ProcessEnv;
|
|
72
|
-
homedir?: () => string;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export class ConfigError extends Error {
|
|
76
|
-
readonly filePath: string;
|
|
77
|
-
readonly issues: string[];
|
|
78
|
-
|
|
79
|
-
constructor(filePath: string, issues: string[], cause?: unknown) {
|
|
80
|
-
const detail =
|
|
81
|
-
issues.length > 0 ? issues.join("; ") : "unknown validation error";
|
|
82
|
-
super(`Invalid OffRouter config in ${filePath}: ${detail}`, {
|
|
83
|
-
cause,
|
|
84
|
-
});
|
|
85
|
-
this.name = "ConfigError";
|
|
86
|
-
this.filePath = filePath;
|
|
87
|
-
this.issues = issues;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
type AllowedNode = AllowedShape | "leaf" | "record";
|
|
92
|
-
interface AllowedShape {
|
|
93
|
-
[key: string]: AllowedNode;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function allowedFromZodShape(shape: z.ZodRawShape): AllowedShape {
|
|
97
|
-
return Object.fromEntries(
|
|
98
|
-
Object.keys(shape).map((key) => [key, "leaf" as const]),
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const ProviderConfigShape = {
|
|
103
|
-
enabled: z.boolean().optional(),
|
|
104
|
-
accounts: z
|
|
105
|
-
.array(z.object({ id: z.string().min(1), label: z.string().optional() }).strict())
|
|
106
|
-
.optional(),
|
|
107
|
-
} satisfies z.ZodRawShape;
|
|
108
|
-
|
|
109
|
-
const PolicySectionShape = {
|
|
110
|
-
subscription_first: z.boolean().optional(),
|
|
111
|
-
allow_api_key_fallback: z.boolean().optional(),
|
|
112
|
-
allow_third_party_subscription_adapters: z.boolean().optional(),
|
|
113
|
-
denied_providers: z.array(z.string().min(1)).optional(),
|
|
114
|
-
near_limit_threshold: z.number().min(0).max(1).optional(),
|
|
115
|
-
} satisfies z.ZodRawShape;
|
|
116
|
-
|
|
117
|
-
const RootConfigShape = {
|
|
118
|
-
allowlisted_profiles: z.array(z.string().min(1)).optional(),
|
|
119
|
-
denied_profile_patterns: z.array(z.string().min(1)).optional(),
|
|
120
|
-
policy: z.object(PolicySectionShape).strict().optional(),
|
|
121
|
-
providers: z
|
|
122
|
-
.record(z.string().min(1), z.object(ProviderConfigShape).strict())
|
|
123
|
-
.optional(),
|
|
124
|
-
} satisfies z.ZodRawShape;
|
|
125
|
-
|
|
126
|
-
const ProfileFileShape = {
|
|
127
|
-
id: z.string().min(1).optional(),
|
|
128
|
-
denied_providers: z.array(z.string().min(1)).optional(),
|
|
129
|
-
allow_api_key_fallback: z.boolean().optional(),
|
|
130
|
-
subscription_first: z.boolean().optional(),
|
|
131
|
-
} satisfies z.ZodRawShape;
|
|
132
|
-
|
|
133
|
-
const RootConfigSchema = z.object(RootConfigShape).strict();
|
|
134
|
-
const ProfileFileSchema = z.object(ProfileFileShape).strict();
|
|
135
|
-
|
|
136
|
-
const PROVIDER_ALLOWED: AllowedShape = {
|
|
137
|
-
...allowedFromZodShape(ProviderConfigShape),
|
|
138
|
-
accounts: "leaf",
|
|
139
|
-
};
|
|
140
|
-
const ROOT_ALLOWED: AllowedShape = {
|
|
141
|
-
...allowedFromZodShape(RootConfigShape),
|
|
142
|
-
policy: allowedFromZodShape(PolicySectionShape),
|
|
143
|
-
providers: "record",
|
|
144
|
-
};
|
|
145
|
-
const PROFILE_ALLOWED: AllowedShape = allowedFromZodShape(ProfileFileShape);
|
|
146
|
-
|
|
147
|
-
type RootConfig = z.infer<typeof RootConfigSchema>;
|
|
148
|
-
type ProfileFile = z.infer<typeof ProfileFileSchema>;
|
|
149
|
-
|
|
150
|
-
const DEFAULT_DENIED_PROFILE_PATTERNS = ["*-work"] as const;
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Safe default config: work profiles denied, nothing allowlisted, no providers.
|
|
154
|
-
*/
|
|
155
|
-
export function defaultConfig(homeDir = ""): OffRouterConfig {
|
|
156
|
-
return {
|
|
157
|
-
homeDir,
|
|
158
|
-
sources: [],
|
|
159
|
-
policy: {
|
|
160
|
-
allowlistedProfiles: [],
|
|
161
|
-
deniedProfilePatterns: [...DEFAULT_DENIED_PROFILE_PATTERNS],
|
|
162
|
-
allowThirdPartySubscriptionAdapters: false,
|
|
163
|
-
deniedProviders: [],
|
|
164
|
-
},
|
|
165
|
-
policyDefaults: {
|
|
166
|
-
subscriptionFirst: true,
|
|
167
|
-
allowApiKeyFallback: false,
|
|
168
|
-
},
|
|
169
|
-
providers: {},
|
|
170
|
-
profiles: {},
|
|
171
|
-
warnings: [],
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export function resolveOffRouterHome(
|
|
176
|
-
options: ResolveHomeOptions = {},
|
|
177
|
-
): string {
|
|
178
|
-
const env = options.env ?? process.env;
|
|
179
|
-
const home = options.homedir ?? osHomedir;
|
|
180
|
-
const override = env.OFFROUTER_HOME?.trim();
|
|
181
|
-
if (override) {
|
|
182
|
-
return resolve(override);
|
|
183
|
-
}
|
|
184
|
-
return resolve(join(home(), ".offrouter"));
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
188
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function formatZodIssues(error: z.ZodError): string[] {
|
|
192
|
-
return error.issues.map((issue) => {
|
|
193
|
-
const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
|
|
194
|
-
return `${path}: ${issue.message}`;
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Non-Zod path walker that flags keys absent from a schema tree.
|
|
200
|
-
* Uses Zod internals only as type-shape guidance via a parallel known-keys model.
|
|
201
|
-
*/
|
|
202
|
-
function collectUnknownKeyWarnings(
|
|
203
|
-
value: unknown,
|
|
204
|
-
allowed: AllowedShape,
|
|
205
|
-
filePath: string,
|
|
206
|
-
pathPrefix: string,
|
|
207
|
-
): ConfigWarning[] {
|
|
208
|
-
if (!isPlainObject(value)) {
|
|
209
|
-
return [];
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const warnings: ConfigWarning[] = [];
|
|
213
|
-
for (const key of Object.keys(value)) {
|
|
214
|
-
const keyPath = pathPrefix ? `${pathPrefix}.${key}` : key;
|
|
215
|
-
const child = allowed[key];
|
|
216
|
-
if (child === undefined) {
|
|
217
|
-
warnings.push({
|
|
218
|
-
filePath,
|
|
219
|
-
path: keyPath,
|
|
220
|
-
message: `Unknown config key "${keyPath}" in ${filePath}`,
|
|
221
|
-
});
|
|
222
|
-
continue;
|
|
223
|
-
}
|
|
224
|
-
if (child === "leaf") {
|
|
225
|
-
continue;
|
|
226
|
-
}
|
|
227
|
-
if (child === "record") {
|
|
228
|
-
const recordValue = value[key];
|
|
229
|
-
if (isPlainObject(recordValue)) {
|
|
230
|
-
for (const nestedKey of Object.keys(recordValue)) {
|
|
231
|
-
const nestedPath = `${keyPath}.${nestedKey}`;
|
|
232
|
-
warnings.push(
|
|
233
|
-
...collectUnknownKeyWarnings(
|
|
234
|
-
recordValue[nestedKey],
|
|
235
|
-
PROVIDER_ALLOWED,
|
|
236
|
-
filePath,
|
|
237
|
-
nestedPath,
|
|
238
|
-
),
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
continue;
|
|
243
|
-
}
|
|
244
|
-
warnings.push(
|
|
245
|
-
...collectUnknownKeyWarnings(value[key], child, filePath, keyPath),
|
|
246
|
-
);
|
|
247
|
-
}
|
|
248
|
-
return warnings;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function parseTomlFile(content: string, filePath: string): unknown {
|
|
252
|
-
try {
|
|
253
|
-
return parseToml(content);
|
|
254
|
-
} catch (err) {
|
|
255
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
256
|
-
throw new ConfigError(filePath, [`parse error: ${message}`], err);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function validateRoot(
|
|
261
|
-
raw: unknown,
|
|
262
|
-
filePath: string,
|
|
263
|
-
): { data: RootConfig; warnings: ConfigWarning[] } {
|
|
264
|
-
const warnings = collectUnknownKeyWarnings(raw, ROOT_ALLOWED, filePath, "");
|
|
265
|
-
// Strip unknown keys before strict Zod parse so warnings ≠ hard failure.
|
|
266
|
-
const stripped = stripUnknown(raw, ROOT_ALLOWED);
|
|
267
|
-
const parsed = RootConfigSchema.safeParse(stripped);
|
|
268
|
-
if (!parsed.success) {
|
|
269
|
-
throw new ConfigError(filePath, formatZodIssues(parsed.error), parsed.error);
|
|
270
|
-
}
|
|
271
|
-
return { data: parsed.data, warnings };
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function validateProfile(
|
|
275
|
-
raw: unknown,
|
|
276
|
-
filePath: string,
|
|
277
|
-
fallbackId: string,
|
|
278
|
-
): { data: ConfiguredProfile; warnings: ConfigWarning[] } {
|
|
279
|
-
const warnings = collectUnknownKeyWarnings(
|
|
280
|
-
raw,
|
|
281
|
-
PROFILE_ALLOWED,
|
|
282
|
-
filePath,
|
|
283
|
-
"",
|
|
284
|
-
);
|
|
285
|
-
const stripped = stripUnknown(raw, PROFILE_ALLOWED);
|
|
286
|
-
const parsed = ProfileFileSchema.safeParse(stripped);
|
|
287
|
-
if (!parsed.success) {
|
|
288
|
-
throw new ConfigError(filePath, formatZodIssues(parsed.error), parsed.error);
|
|
289
|
-
}
|
|
290
|
-
const profile: ProfileFile = parsed.data;
|
|
291
|
-
const data: ConfiguredProfile = {
|
|
292
|
-
id: profile.id ?? fallbackId,
|
|
293
|
-
};
|
|
294
|
-
if (profile.denied_providers !== undefined) {
|
|
295
|
-
data.deniedProviders = profile.denied_providers;
|
|
296
|
-
}
|
|
297
|
-
if (profile.allow_api_key_fallback !== undefined) {
|
|
298
|
-
data.allowApiKeyFallback = profile.allow_api_key_fallback;
|
|
299
|
-
}
|
|
300
|
-
if (profile.subscription_first !== undefined) {
|
|
301
|
-
data.subscriptionFirst = profile.subscription_first;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
return {
|
|
305
|
-
data,
|
|
306
|
-
warnings,
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function stripUnknown(value: unknown, allowed: AllowedShape): unknown {
|
|
311
|
-
if (!isPlainObject(value)) {
|
|
312
|
-
return value;
|
|
313
|
-
}
|
|
314
|
-
const out: Record<string, unknown> = {};
|
|
315
|
-
for (const [key, child] of Object.entries(allowed)) {
|
|
316
|
-
if (!(key in value)) continue;
|
|
317
|
-
const v = value[key];
|
|
318
|
-
if (child === "leaf") {
|
|
319
|
-
out[key] = v;
|
|
320
|
-
} else if (child === "record") {
|
|
321
|
-
if (isPlainObject(v)) {
|
|
322
|
-
const recordOut: Record<string, unknown> = {};
|
|
323
|
-
for (const [nestedKey, nestedVal] of Object.entries(v)) {
|
|
324
|
-
recordOut[nestedKey] = stripUnknown(nestedVal, PROVIDER_ALLOWED);
|
|
325
|
-
}
|
|
326
|
-
out[key] = recordOut;
|
|
327
|
-
} else {
|
|
328
|
-
out[key] = v;
|
|
329
|
-
}
|
|
330
|
-
} else {
|
|
331
|
-
out[key] = stripUnknown(v, child);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
return out;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function mergeRootInto(
|
|
338
|
-
target: OffRouterConfig,
|
|
339
|
-
root: RootConfig,
|
|
340
|
-
sourcePath: string,
|
|
341
|
-
): void {
|
|
342
|
-
target.sources.push(sourcePath);
|
|
343
|
-
|
|
344
|
-
if (root.allowlisted_profiles) {
|
|
345
|
-
target.policy.allowlistedProfiles = uniqueStrings([
|
|
346
|
-
...target.policy.allowlistedProfiles,
|
|
347
|
-
...root.allowlisted_profiles,
|
|
348
|
-
]);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
if (root.denied_profile_patterns) {
|
|
352
|
-
target.policy.deniedProfilePatterns = uniqueStrings([
|
|
353
|
-
...(target.policy.deniedProfilePatterns ?? [
|
|
354
|
-
...DEFAULT_DENIED_PROFILE_PATTERNS,
|
|
355
|
-
]),
|
|
356
|
-
...root.denied_profile_patterns,
|
|
357
|
-
]);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
if (root.policy) {
|
|
361
|
-
if (root.policy.subscription_first !== undefined) {
|
|
362
|
-
target.policyDefaults.subscriptionFirst = root.policy.subscription_first;
|
|
363
|
-
}
|
|
364
|
-
if (root.policy.allow_api_key_fallback !== undefined) {
|
|
365
|
-
target.policyDefaults.allowApiKeyFallback =
|
|
366
|
-
root.policy.allow_api_key_fallback;
|
|
367
|
-
}
|
|
368
|
-
if (root.policy.allow_third_party_subscription_adapters !== undefined) {
|
|
369
|
-
target.policy.allowThirdPartySubscriptionAdapters =
|
|
370
|
-
root.policy.allow_third_party_subscription_adapters;
|
|
371
|
-
}
|
|
372
|
-
if (root.policy.near_limit_threshold !== undefined) {
|
|
373
|
-
target.policy.nearLimitThreshold = root.policy.near_limit_threshold;
|
|
374
|
-
}
|
|
375
|
-
if (root.policy.denied_providers) {
|
|
376
|
-
target.policy.deniedProviders = uniqueStrings([
|
|
377
|
-
...(target.policy.deniedProviders ?? []),
|
|
378
|
-
...root.policy.denied_providers,
|
|
379
|
-
]);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
if (root.providers) {
|
|
384
|
-
for (const [id, provider] of Object.entries(root.providers)) {
|
|
385
|
-
const accounts: ConfiguredAccount[] = provider.accounts
|
|
386
|
-
? provider.accounts.map((a: { id: string; label?: string }) => ({ id: a.id, label: a.label }))
|
|
387
|
-
: [{ id }];
|
|
388
|
-
target.providers[id] = {
|
|
389
|
-
id,
|
|
390
|
-
enabled: provider.enabled ?? true,
|
|
391
|
-
accounts,
|
|
392
|
-
};
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function uniqueStrings(values: string[]): string[] {
|
|
398
|
-
return [...new Set(values)];
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
async function readOptionalFile(path: string): Promise<string | null> {
|
|
402
|
-
try {
|
|
403
|
-
return await readFile(path, "utf8");
|
|
404
|
-
} catch (err) {
|
|
405
|
-
if (
|
|
406
|
-
err &&
|
|
407
|
-
typeof err === "object" &&
|
|
408
|
-
"code" in err &&
|
|
409
|
-
(err as { code?: string }).code === "ENOENT"
|
|
410
|
-
) {
|
|
411
|
-
return null;
|
|
412
|
-
}
|
|
413
|
-
throw err;
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
async function loadRootFile(
|
|
418
|
-
filePath: string,
|
|
419
|
-
target: OffRouterConfig,
|
|
420
|
-
): Promise<void> {
|
|
421
|
-
const content = await readOptionalFile(filePath);
|
|
422
|
-
if (content === null) {
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
// Empty file is valid and means no overrides.
|
|
426
|
-
if (content.trim() === "") {
|
|
427
|
-
target.sources.push(filePath);
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
const raw = parseTomlFile(content, filePath);
|
|
431
|
-
const { data, warnings } = validateRoot(raw, filePath);
|
|
432
|
-
target.warnings.push(...warnings);
|
|
433
|
-
mergeRootInto(target, data, filePath);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
async function loadProfileDir(
|
|
437
|
-
dirPath: string,
|
|
438
|
-
target: OffRouterConfig,
|
|
439
|
-
): Promise<void> {
|
|
440
|
-
let entries: string[];
|
|
441
|
-
try {
|
|
442
|
-
entries = await readdir(dirPath);
|
|
443
|
-
} catch (err) {
|
|
444
|
-
if (
|
|
445
|
-
err &&
|
|
446
|
-
typeof err === "object" &&
|
|
447
|
-
"code" in err &&
|
|
448
|
-
(err as { code?: string }).code === "ENOENT"
|
|
449
|
-
) {
|
|
450
|
-
return;
|
|
451
|
-
}
|
|
452
|
-
throw err;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
const tomlFiles = entries
|
|
456
|
-
.filter((name) => name.endsWith(".toml"))
|
|
457
|
-
.sort((a, b) => a.localeCompare(b));
|
|
458
|
-
|
|
459
|
-
for (const name of tomlFiles) {
|
|
460
|
-
const filePath = join(dirPath, name);
|
|
461
|
-
const content = await readFile(filePath, "utf8");
|
|
462
|
-
const raw =
|
|
463
|
-
content.trim() === "" ? {} : parseTomlFile(content, filePath);
|
|
464
|
-
const fallbackId = name.replace(/\.toml$/i, "");
|
|
465
|
-
const { data, warnings } = validateProfile(raw, filePath, fallbackId);
|
|
466
|
-
target.warnings.push(...warnings);
|
|
467
|
-
target.sources.push(filePath);
|
|
468
|
-
if (target.profiles[data.id]) {
|
|
469
|
-
target.warnings.push({
|
|
470
|
-
filePath,
|
|
471
|
-
path: `profiles.${data.id}`,
|
|
472
|
-
message: `Duplicate profile id "${data.id}" in ${filePath}; later profile file overwrote an earlier definition.`,
|
|
473
|
-
});
|
|
474
|
-
}
|
|
475
|
-
target.profiles[data.id] = data;
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/**
|
|
480
|
-
* Load OffRouter configuration.
|
|
481
|
-
*
|
|
482
|
-
* Resolution order:
|
|
483
|
-
* 1. defaults (deny *-work, empty allowlist, no providers)
|
|
484
|
-
* 2. `$OFFROUTER_HOME/config.toml` or `~/.offrouter/config.toml`
|
|
485
|
-
* 3. `$OFFROUTER_HOME/profiles/*.toml`
|
|
486
|
-
* 4. `<workspace>/.offrouter/config.toml` only when workspaceTrusted
|
|
487
|
-
*/
|
|
488
|
-
export async function loadConfig(
|
|
489
|
-
options: LoadConfigOptions = {},
|
|
490
|
-
): Promise<OffRouterConfig> {
|
|
491
|
-
const homeDir = resolveOffRouterHome(options);
|
|
492
|
-
const config = defaultConfig(homeDir);
|
|
493
|
-
|
|
494
|
-
await loadRootFile(join(homeDir, "config.toml"), config);
|
|
495
|
-
await loadProfileDir(join(homeDir, "profiles"), config);
|
|
496
|
-
|
|
497
|
-
if (options.workspaceDir && options.workspaceTrusted) {
|
|
498
|
-
await loadRootFile(
|
|
499
|
-
join(options.workspaceDir, ".offrouter", "config.toml"),
|
|
500
|
-
config,
|
|
501
|
-
);
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
return config;
|
|
505
|
-
}
|