forked from zhongkaifu/TensorSharp
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathConfigFileArgs.cs
More file actions
453 lines (399 loc) · 20 KB
/
Copy pathConfigFileArgs.cs
File metadata and controls
453 lines (399 loc) · 20 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
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
// Copyright (c) Zhongkai Fu. All rights reserved.
// https://github.com/zhongkaifu/TensorSharp
//
// This file is part of TensorSharp.
//
// TensorSharp is licensed under the BSD-3-Clause license found in the LICENSE file in the root directory of this source tree.
//
// TensorSharp is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the BSD-3-Clause License for more details.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace TensorSharp.Runtime
{
/// <summary>
/// Lets the CLI and the server read their startup options from a JSON
/// configuration file in addition to the command line. A
/// <c>--config <path.json></c> flag names a file whose keys are the same
/// long option names both hosts already accept (with or without the leading
/// <c>--</c>); <see cref="Expand"/> translates each key/value into the
/// equivalent argv tokens and splices them in <em>before</em> the caller's
/// real command-line arguments.
///
/// Because both option parsers use last-one-wins semantics for scalar
/// options, placing the file-derived tokens first means any value the
/// operator also passes on the command line overrides the file. When the
/// same key appears in several <c>--config</c> files, later files win over
/// earlier ones (they are appended in the order the flags appear).
///
/// Three conveniences build on the basic key/value mapping:
/// <list type="bullet">
/// <item><b>Variables.</b> A reserved <c>"variables"</c> object defines
/// names that any string value can reference with <c>${name}</c> — so a
/// shared model root is written once. A reference not found among the
/// variables falls back to an environment variable of the same name;
/// variables may reference other variables.</item>
/// <item><b>Auto-download.</b> A file option may be an object
/// <c>{ "path": "...", "urls": ["...", "..."] }</c>. If <c>path</c> is missing
/// on disk it is downloaded from the first working URL and saved there, so the
/// next run reuses the local copy. See <see cref="ModelDownloader"/>.</item>
/// </list>
///
/// Example config file:
/// <code>
/// {
/// "variables": { "root": "C:\\models" },
/// "backend": "ggml_cuda",
/// "max-tokens": 4096,
/// "temperature": 0.7,
/// "stop": ["</s>", "<|eot|>"],
/// "model": {
/// "path": "${root}/gemma-4-E4B-it-Q8_0.gguf",
/// "urls": [ "https://example.com/gemma-4-E4B-it-Q8_0.gguf" ]
/// }
/// }
/// </code>
/// Value shapes: a string or number becomes <c>--key value</c>; a boolean
/// <c>true</c> becomes the bare switch <c>--key</c> (a <c>false</c> is
/// skipped, so use the explicit negation key — e.g. <c>"no-continuous-batching": true</c>
/// — to turn something off); an array becomes a repeated flag
/// (<c>--key v1 --key v2</c>); an object is a downloadable-file spec.
/// </summary>
public static class ConfigFileArgs
{
/// <summary>The flag that names a JSON configuration file.</summary>
public const string ConfigFlag = "--config";
// Keys with a meaning of their own rather than a flag to emit.
private static readonly HashSet<string> ReservedKeys = new(StringComparer.OrdinalIgnoreCase)
{
"variables", "vars", "$schema",
};
private static readonly JsonDocumentOptions ParseOptions = new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip,
};
private static readonly Regex VariablePattern = new(@"\$\{([A-Za-z0-9_.\-]+)\}", RegexOptions.Compiled);
/// <summary>
/// Expand every <c>--config <path></c> flag in <paramref name="args"/>
/// into the argv tokens it represents and return the merged argument
/// list: file-derived tokens first (in flag order), then every other
/// argument in its original order. The <c>--config</c> flags themselves
/// are removed. When no <c>--config</c> flag is present the input array
/// is returned unchanged. Download progress (when a file must be fetched)
/// is written to <see cref="Console.Error"/>.
/// </summary>
/// <exception cref="ArgumentException">A <c>--config</c> flag has no path, or a config file is malformed.</exception>
/// <exception cref="FileNotFoundException">A named config file, or a referenced file with no download URL, does not exist.</exception>
/// <exception cref="IOException">A referenced file could not be downloaded from any URL.</exception>
public static string[] Expand(string[] args) =>
Expand(args, Console.Error, interactiveProgress: !Console.IsErrorRedirected);
/// <summary>
/// Testable overload: routes download progress to an explicit writer and
/// controls whether progress overwrites a single line (TTY) or emits
/// discrete lines (log sink).
/// </summary>
internal static string[] Expand(string[] args, TextWriter log, bool interactiveProgress)
{
if (args == null || args.Length == 0)
return args ?? Array.Empty<string>();
var configPaths = new List<string>();
var passThrough = new List<string>(args.Length);
for (int i = 0; i < args.Length; i++)
{
string arg = args[i];
if (string.Equals(arg, ConfigFlag, StringComparison.OrdinalIgnoreCase))
{
if (i + 1 >= args.Length)
throw new ArgumentException($"Missing value for option '{ConfigFlag}'. Expected a path to a JSON configuration file.");
configPaths.Add(args[++i]);
continue;
}
const string inlinePrefix = ConfigFlag + "=";
if (arg.StartsWith(inlinePrefix, StringComparison.OrdinalIgnoreCase))
{
configPaths.Add(arg.Substring(inlinePrefix.Length));
continue;
}
passThrough.Add(arg);
}
if (configPaths.Count == 0)
return args;
var merged = new List<string>(args.Length);
var context = new ExpandContext(log ?? TextWriter.Null, interactiveProgress);
foreach (string configPath in configPaths)
ExpandFile(configPath, merged, context);
merged.AddRange(passThrough);
return merged.ToArray();
}
private readonly struct ExpandContext
{
public ExpandContext(TextWriter log, bool interactiveProgress)
{
Log = log;
InteractiveProgress = interactiveProgress;
}
public TextWriter Log { get; }
public bool InteractiveProgress { get; }
}
private static void ExpandFile(string path, List<string> output, ExpandContext context)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException($"Empty value for option '{ConfigFlag}'. Expected a path to a JSON configuration file.");
string fullPath = Path.GetFullPath(path);
if (!File.Exists(fullPath))
throw new FileNotFoundException($"Configuration file not found: {fullPath}", fullPath);
string? configDirectory = Path.GetDirectoryName(fullPath);
string json = File.ReadAllText(fullPath);
JsonDocument document;
try
{
document = JsonDocument.Parse(json, ParseOptions);
}
catch (JsonException ex)
{
throw new ArgumentException($"Configuration file '{fullPath}' is not valid JSON: {ex.Message}", ex);
}
using (document)
{
JsonElement root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
throw new ArgumentException($"Configuration file '{fullPath}' must contain a JSON object at its root, but found {root.ValueKind}.");
var variables = VariableResolver.FromConfig(fullPath, root);
foreach (JsonProperty property in root.EnumerateObject())
{
if (ReservedKeys.Contains(property.Name))
continue;
AppendProperty(fullPath, configDirectory, variables, property.Name, property.Value, output, context);
}
}
}
private static void AppendProperty(
string configPath,
string? configDirectory,
VariableResolver variables,
string key,
JsonElement value,
List<string> output,
ExpandContext context)
{
string flag = NormalizeFlag(configPath, key);
switch (value.ValueKind)
{
case JsonValueKind.True:
// Boolean switch (e.g. "think": true -> --think).
output.Add(flag);
break;
case JsonValueKind.False:
case JsonValueKind.Null:
// A disabled/absent switch contributes nothing. To turn an
// option off, name its explicit negation flag instead
// (e.g. "no-continuous-batching": true).
break;
case JsonValueKind.String:
output.Add(flag);
output.Add(variables.Substitute(value.GetString()!));
break;
case JsonValueKind.Number:
output.Add(flag);
output.Add(value.GetRawText());
break;
case JsonValueKind.Object:
// Downloadable-file spec: { "path": ..., "urls": [...] }.
output.Add(flag);
output.Add(ResolveDownloadSpec(configPath, configDirectory, variables, key, value, context));
break;
case JsonValueKind.Array:
foreach (JsonElement element in value.EnumerateArray())
AppendArrayElement(configPath, configDirectory, variables, key, flag, element, output, context);
break;
default:
throw new ArgumentException(
$"Configuration file '{configPath}' option '{key}' has unsupported value type {value.ValueKind}.");
}
}
private static void AppendArrayElement(
string configPath,
string? configDirectory,
VariableResolver variables,
string key,
string flag,
JsonElement element,
List<string> output,
ExpandContext context)
{
switch (element.ValueKind)
{
case JsonValueKind.String:
output.Add(flag);
output.Add(variables.Substitute(element.GetString()!));
break;
case JsonValueKind.Number:
output.Add(flag);
output.Add(element.GetRawText());
break;
case JsonValueKind.Object:
output.Add(flag);
output.Add(ResolveDownloadSpec(configPath, configDirectory, variables, key, element, context));
break;
default:
throw new ArgumentException(
$"Configuration file '{configPath}' option '{key}' has an array element of type {element.ValueKind}; only strings, numbers, and download objects are supported in arrays.");
}
}
/// <summary>
/// Resolve a <c>{ "path": ..., "url"|"urls": ..., "sha256": ... }</c> object
/// into a concrete local path, downloading the file from the first working
/// URL when it is not already present. Relative paths resolve against the
/// config file's directory so a config and its models can travel together.
/// </summary>
private static string ResolveDownloadSpec(
string configPath,
string? configDirectory,
VariableResolver variables,
string key,
JsonElement spec,
ExpandContext context)
{
if (!spec.TryGetProperty("path", out JsonElement pathElement) || pathElement.ValueKind != JsonValueKind.String)
throw new ArgumentException(
$"Configuration file '{configPath}' option '{key}' is an object but has no string \"path\" field. " +
"A downloadable-file entry looks like {{ \"path\": \"...\", \"urls\": [ \"...\" ] }}.");
string rawPath = variables.Substitute(pathElement.GetString()!);
string localPath = Path.IsPathRooted(rawPath)
? Path.GetFullPath(rawPath)
: Path.GetFullPath(Path.Combine(configDirectory ?? Directory.GetCurrentDirectory(), rawPath));
var urls = ReadUrls(configPath, variables, key, spec);
string? sha256 = spec.TryGetProperty("sha256", out JsonElement shaElement) && shaElement.ValueKind == JsonValueKind.String
? shaElement.GetString()
: null;
if (File.Exists(localPath))
{
context.Log.WriteLine($"[model-download] {key}: using cached file at {localPath}");
return localPath;
}
if (urls.Count == 0)
throw new FileNotFoundException(
$"Configuration file '{configPath}' option '{key}' path not found and no download URL was provided: {localPath}",
localPath);
context.Log.WriteLine($"[model-download] {key}: '{localPath}' not found locally; attempting download from {urls.Count} source(s)");
ModelDownloader.Download(localPath, urls, sha256, key, context.Log, context.InteractiveProgress);
return localPath;
}
private static List<string> ReadUrls(string configPath, VariableResolver variables, string key, JsonElement spec)
{
var urls = new List<string>();
if (spec.TryGetProperty("urls", out JsonElement urlsElement))
{
if (urlsElement.ValueKind != JsonValueKind.Array)
throw new ArgumentException($"Configuration file '{configPath}' option '{key}' has a \"urls\" field that is not an array.");
foreach (JsonElement urlElement in urlsElement.EnumerateArray())
{
if (urlElement.ValueKind != JsonValueKind.String)
throw new ArgumentException($"Configuration file '{configPath}' option '{key}' has a non-string entry in \"urls\".");
urls.Add(variables.Substitute(urlElement.GetString()!));
}
}
if (spec.TryGetProperty("url", out JsonElement singleUrl))
{
if (singleUrl.ValueKind != JsonValueKind.String)
throw new ArgumentException($"Configuration file '{configPath}' option '{key}' has a \"url\" field that is not a string.");
urls.Add(variables.Substitute(singleUrl.GetString()!));
}
return urls;
}
private static string NormalizeFlag(string configPath, string key)
{
if (string.IsNullOrWhiteSpace(key))
throw new ArgumentException($"Configuration file '{configPath}' contains an empty option name.");
string trimmed = key.Trim();
return trimmed.StartsWith("--", StringComparison.Ordinal) ? trimmed : "--" + trimmed;
}
/// <summary>
/// Resolves <c>${name}</c> references in string values against the config's
/// <c>"variables"</c> object, falling back to environment variables, and
/// supports variables that reference other variables (with cycle detection).
/// </summary>
private sealed class VariableResolver
{
private readonly string _configPath;
private readonly Dictionary<string, string> _raw; // as written in the file (may contain ${...})
private readonly Dictionary<string, string> _resolved; // fully expanded, memoised
private VariableResolver(string configPath, Dictionary<string, string> raw)
{
_configPath = configPath;
_raw = raw;
_resolved = new Dictionary<string, string>(StringComparer.Ordinal);
}
public static VariableResolver FromConfig(string configPath, JsonElement root)
{
var raw = new Dictionary<string, string>(StringComparer.Ordinal);
if (TryGetReserved(root, "variables", out JsonElement vars) || TryGetReserved(root, "vars", out vars))
{
if (vars.ValueKind != JsonValueKind.Object)
throw new ArgumentException($"Configuration file '{configPath}' \"variables\" must be a JSON object.");
foreach (JsonProperty v in vars.EnumerateObject())
{
raw[v.Name] = v.Value.ValueKind switch
{
JsonValueKind.String => v.Value.GetString()!,
JsonValueKind.Number => v.Value.GetRawText(),
_ => throw new ArgumentException(
$"Configuration file '{configPath}' variable '{v.Name}' must be a string or number, but is {v.Value.ValueKind}."),
};
}
}
return new VariableResolver(configPath, raw);
}
public string Substitute(string input)
{
if (string.IsNullOrEmpty(input) || input.IndexOf("${", StringComparison.Ordinal) < 0)
return input;
return Substitute(input, new HashSet<string>(StringComparer.Ordinal));
}
private string Substitute(string input, HashSet<string> visiting)
{
return VariablePattern.Replace(input, match => Resolve(match.Groups[1].Value, visiting));
}
private string Resolve(string name, HashSet<string> visiting)
{
if (_resolved.TryGetValue(name, out string? cached))
return cached;
if (_raw.TryGetValue(name, out string? rawValue))
{
if (!visiting.Add(name))
throw new ArgumentException($"Configuration file '{_configPath}' has a cyclic variable reference involving '{name}'.");
string result = Substitute(rawValue, visiting);
visiting.Remove(name);
_resolved[name] = result;
return result;
}
string? env = Environment.GetEnvironmentVariable(name);
if (env != null)
{
_resolved[name] = env;
return env;
}
throw new ArgumentException(
$"Configuration file '{_configPath}' references undefined variable '${{{name}}}' " +
"(not found among \"variables\" or environment variables).");
}
private static bool TryGetReserved(JsonElement root, string name, out JsonElement value)
{
foreach (JsonProperty p in root.EnumerateObject())
{
if (string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = p.Value;
return true;
}
}
value = default;
return false;
}
}
}
}