tinker-agent 1.3.0 → 1.5.0

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +271 -72
  3. package/bin/tinker.js +75 -25
  4. package/package.json +12 -3
  5. package/src/agent/runtime-session.ts +113 -15
  6. package/src/cli/command-line.ts +291 -0
  7. package/src/cli/config.ts +158 -262
  8. package/src/cli/index.ts +33 -21
  9. package/src/cli/main.ts +213 -0
  10. package/src/cli/model-profiles.ts +226 -72
  11. package/src/cli/output.ts +113 -0
  12. package/src/cli/package-metadata.ts +36 -0
  13. package/src/cli/prompt-source.ts +229 -0
  14. package/src/cli/public-cli-contract.ts +69 -0
  15. package/src/cli/public-config-contract.ts +732 -0
  16. package/src/cli/run-runner.ts +17 -12
  17. package/src/cli/runner-dependencies.ts +108 -0
  18. package/src/cli/tui-memory.ts +67 -0
  19. package/src/cli/tui-runner.tsx +79 -49
  20. package/src/context/context-policy.ts +2 -2
  21. package/src/events/stdout-event-printer.ts +1 -0
  22. package/src/mcp/mcp-manager.ts +2 -19
  23. package/src/mcp/mcp-tool-executor.ts +3 -4
  24. package/src/memory/contracts.ts +148 -0
  25. package/src/memory/embedding-client.ts +105 -0
  26. package/src/memory/memory-coordinator.ts +556 -0
  27. package/src/memory/memory-extractor.ts +231 -0
  28. package/src/memory/memory-log.ts +88 -0
  29. package/src/memory/memory-search-tool.ts +100 -0
  30. package/src/memory/memory-store.ts +687 -0
  31. package/src/memory/vector.ts +153 -0
  32. package/src/model/fake-model-client.ts +971 -3
  33. package/src/model/model-context-profile.ts +0 -30
  34. package/src/observation/observation-builder.ts +20 -0
  35. package/src/session/session-store.ts +123 -0
  36. package/src/tools/bash.ts +8 -25
  37. package/src/tools/grep.ts +9 -1
  38. package/src/tools/registry.ts +19 -1
  39. package/src/tools/ripgrep.ts +24 -27
  40. package/src/tools/types.ts +16 -0
  41. package/src/tools/web-fetch/index.ts +2 -15
  42. package/src/tui/app.tsx +72 -2
  43. package/src/tui/clipboard.ts +22 -0
  44. package/src/tui/components/footer.tsx +9 -4
  45. package/src/tui/components/memory-browser.tsx +151 -0
  46. package/src/tui/components/prompt-input.tsx +6 -3
  47. package/src/tui/event-store.ts +9 -2
  48. package/src/tui/slash-commands.ts +88 -24
  49. package/src/tui/workspace-file-search.ts +78 -71
@@ -0,0 +1,213 @@
1
+ import type { SessionId } from "../ids/runtime-id";
2
+ import { createUuidV7 } from "../ids/uuid-v7";
3
+ import { parseCommandLine, type CommandLineResult } from "./command-line";
4
+ import type {
5
+ ResolvedPublicConfig,
6
+ RunnerConfig,
7
+ RunnerConfigSelection,
8
+ } from "./config";
9
+ import {
10
+ CliUsageError,
11
+ flushCliOutput,
12
+ renderCliFailure,
13
+ renderUsageError,
14
+ writeCliOutput,
15
+ type CliOutputWriter,
16
+ } from "./output";
17
+ import { loadPackageMetadata, type PackageMetadata } from "./package-metadata";
18
+ import {
19
+ PromptInputError,
20
+ resolvePromptSource,
21
+ type PromptReadable,
22
+ type PromptSource,
23
+ type ResolvedPrompt,
24
+ } from "./prompt-source";
25
+
26
+ export const BOOTSTRAP_FAILURE_MESSAGE =
27
+ "Tinker failed to start. Reinstall tinker-agent.\n";
28
+
29
+ export type MainInput = {
30
+ readonly args: readonly string[];
31
+ readonly stdin: PromptReadable;
32
+ readonly stdout: CliOutputWriter;
33
+ readonly stderr: CliOutputWriter;
34
+ readonly cwd: string;
35
+ readonly env: NodeJS.ProcessEnv;
36
+ };
37
+
38
+ type ConfigBoundary = {
39
+ readonly resolvePublicConfig: (input: {
40
+ readonly env: NodeJS.ProcessEnv;
41
+ readonly cwd: string;
42
+ }) => Promise<ResolvedPublicConfig>;
43
+ readonly deriveRunnerConfig: (
44
+ snapshot: ResolvedPublicConfig,
45
+ selection: RunnerConfigSelection,
46
+ ) => RunnerConfig;
47
+ };
48
+
49
+ type TuiRunner = {
50
+ readonly runTui: (options: {
51
+ readonly publicConfig: ResolvedPublicConfig;
52
+ readonly initialRunnerConfig: RunnerConfig;
53
+ readonly env: NodeJS.ProcessEnv;
54
+ }) => Promise<void>;
55
+ };
56
+
57
+ type OneShotRunner = {
58
+ readonly runOneShot: (
59
+ prompt: string,
60
+ options: {
61
+ readonly config: RunnerConfig;
62
+ readonly tooling: ResolvedPublicConfig["tooling"];
63
+ readonly stdout: CliOutputWriter;
64
+ readonly stderr: CliOutputWriter;
65
+ readonly env: NodeJS.ProcessEnv;
66
+ },
67
+ ) => Promise<number>;
68
+ };
69
+
70
+ export type MainDependencies = {
71
+ readonly loadPackageMetadata: () => Promise<PackageMetadata>;
72
+ readonly parseCommandLine: (
73
+ args: readonly string[],
74
+ packageVersion: string,
75
+ ) => Promise<CommandLineResult>;
76
+ readonly loadConfigBoundary: () => Promise<ConfigBoundary>;
77
+ readonly createSessionId: () => SessionId;
78
+ readonly resolvePromptSource: (
79
+ source: PromptSource,
80
+ input: { readonly stdin: PromptReadable; readonly cwd: string },
81
+ ) => Promise<ResolvedPrompt>;
82
+ readonly loadTuiRunner: () => Promise<TuiRunner>;
83
+ readonly loadOneShotRunner: () => Promise<OneShotRunner>;
84
+ };
85
+
86
+ const DEFAULT_DEPENDENCIES: MainDependencies = {
87
+ loadPackageMetadata,
88
+ parseCommandLine,
89
+ loadConfigBoundary: () => import("./config"),
90
+ createSessionId: () => createUuidV7() as SessionId,
91
+ resolvePromptSource,
92
+ loadTuiRunner: () => import("./tui-runner"),
93
+ loadOneShotRunner: () => import("./run-runner"),
94
+ };
95
+
96
+ export async function main(
97
+ input: MainInput,
98
+ injected: Partial<MainDependencies> = {},
99
+ ): Promise<number> {
100
+ const dependencies = { ...DEFAULT_DEPENDENCIES, ...injected };
101
+ const args = Object.freeze([...input.args]);
102
+ const env = Object.freeze({ ...input.env }) as NodeJS.ProcessEnv;
103
+ const cwd = input.cwd;
104
+ const finish = async (exitCode: number): Promise<number> => {
105
+ await flushCliOutput(input.stdout);
106
+ await flushCliOutput(input.stderr);
107
+ return exitCode;
108
+ };
109
+
110
+ try {
111
+ let metadata: PackageMetadata;
112
+ try {
113
+ metadata = await dependencies.loadPackageMetadata();
114
+ } catch {
115
+ await writeCliOutput(input.stderr, BOOTSTRAP_FAILURE_MESSAGE);
116
+ return finish(1);
117
+ }
118
+
119
+ let parsed: CommandLineResult;
120
+ try {
121
+ parsed = await dependencies.parseCommandLine(args, metadata.version);
122
+ } catch (error) {
123
+ if (error instanceof CliUsageError) {
124
+ await writeCliOutput(input.stderr, renderUsageError(error));
125
+ return finish(2);
126
+ }
127
+ throw error;
128
+ }
129
+
130
+ if (parsed.type === "terminal") {
131
+ await writeCliOutput(input.stdout, parsed.stdout);
132
+ await writeCliOutput(input.stderr, parsed.stderr);
133
+ return finish(0);
134
+ }
135
+
136
+ let configBoundary: ConfigBoundary;
137
+ let publicConfig: ResolvedPublicConfig;
138
+ let runnerConfig: RunnerConfig;
139
+ try {
140
+ configBoundary = await dependencies.loadConfigBoundary();
141
+ publicConfig = await configBoundary.resolvePublicConfig({ env, cwd });
142
+ runnerConfig = configBoundary.deriveRunnerConfig(publicConfig, {
143
+ sessionId: dependencies.createSessionId(),
144
+ ...(parsed.command.profileName === undefined
145
+ ? {}
146
+ : { profileName: parsed.command.profileName }),
147
+ });
148
+ } catch (error) {
149
+ await writeCliOutput(
150
+ input.stderr,
151
+ renderCliFailure("Configuration failed", error),
152
+ );
153
+ return finish(1);
154
+ }
155
+
156
+ if (parsed.command.type === "tui") {
157
+ try {
158
+ const runner = await dependencies.loadTuiRunner();
159
+ await runner.runTui({
160
+ publicConfig,
161
+ initialRunnerConfig: runnerConfig,
162
+ env,
163
+ });
164
+ return finish(0);
165
+ } catch (error) {
166
+ await writeCliOutput(input.stderr, renderCliFailure("Runtime failed", error));
167
+ return finish(1);
168
+ }
169
+ }
170
+
171
+ let prompt: ResolvedPrompt;
172
+ try {
173
+ prompt = await dependencies.resolvePromptSource(parsed.command.promptSource, {
174
+ stdin: input.stdin,
175
+ cwd,
176
+ });
177
+ } catch (error) {
178
+ if (error instanceof PromptInputError) {
179
+ await writeCliOutput(
180
+ input.stderr,
181
+ renderCliFailure("Prompt input failed", error),
182
+ );
183
+ return finish(error.exitCode);
184
+ }
185
+ await writeCliOutput(
186
+ input.stderr,
187
+ renderCliFailure("Prompt input failed", error),
188
+ );
189
+ return finish(1);
190
+ }
191
+
192
+ try {
193
+ const runner = await dependencies.loadOneShotRunner();
194
+ const exitCode = await runner.runOneShot(prompt.text, {
195
+ config: runnerConfig,
196
+ tooling: publicConfig.tooling,
197
+ stdout: input.stdout,
198
+ stderr: input.stderr,
199
+ env,
200
+ });
201
+ return finish(exitCode);
202
+ } catch (error) {
203
+ await writeCliOutput(input.stderr, renderCliFailure("Runtime failed", error));
204
+ return finish(1);
205
+ }
206
+ } catch (error) {
207
+ await writeCliOutput(
208
+ input.stderr,
209
+ renderCliFailure("Tinker failed unexpectedly", error),
210
+ );
211
+ return finish(1);
212
+ }
213
+ }
@@ -1,9 +1,18 @@
1
- import path from "node:path";
2
1
  import { readFile, rename, unlink, writeFile } from "node:fs/promises";
3
2
  import {
4
3
  createModelContextProfile,
5
4
  type ModelContextProfile,
6
5
  } from "../model/model-context-profile";
6
+ import {
7
+ MEMORY_CONFIG_FIELDS,
8
+ MEMORY_EMBEDDING_FIELDS,
9
+ MODEL_PROFILE_FIELDS,
10
+ MODEL_PROFILES_DOCUMENT_FIELDS,
11
+ MODEL_TOKEN_ESTIMATOR_FIELDS,
12
+ type ModelTokenEstimatorKind,
13
+ type ModelTokenEstimatorMaxRetries,
14
+ } from "./public-config-contract";
15
+ import type { MemoryEmbeddingConfig } from "../memory/contracts";
7
16
 
8
17
  export type ModelProfile = {
9
18
  readonly name: string;
@@ -21,37 +30,26 @@ export type ModelProfile = {
21
30
  export type ModelInputModality = "text" | "image";
22
31
 
23
32
  export type ModelTokenEstimatorProfile = {
24
- readonly kind: "moonshot-estimate-token-count-v1";
33
+ readonly kind: ModelTokenEstimatorKind;
25
34
  readonly model: string;
26
35
  readonly apiBase: string;
27
36
  readonly apiKey: string;
28
37
  readonly timeoutMs: number;
29
- readonly maxRetries: 0;
38
+ readonly maxRetries: ModelTokenEstimatorMaxRetries;
30
39
  };
31
40
 
32
41
  export type ModelProfiles = {
33
42
  readonly defaultProfile: string;
34
43
  readonly profiles: ReadonlyMap<string, ModelProfile>;
44
+ readonly memory?: MemoryConfig;
35
45
  };
36
46
 
37
- export function modelsConfigPath(
38
- env: NodeJS.ProcessEnv = process.env,
39
- ): string | undefined {
40
- const value = env.TINKER_MODELS;
41
- if (value === undefined || value.trim() === "") {
42
- return undefined;
43
- }
44
- return path.isAbsolute(value) ? value : path.resolve(process.cwd(), value);
45
- }
46
-
47
- export async function loadModelProfiles(
48
- env: NodeJS.ProcessEnv = process.env,
49
- ): Promise<ModelProfiles | undefined> {
50
- const configPath = modelsConfigPath(env);
51
- if (configPath === undefined) {
52
- return undefined;
53
- }
47
+ export type MemoryConfig = {
48
+ readonly profile: string;
49
+ readonly embedding: MemoryEmbeddingConfig;
50
+ };
54
51
 
52
+ export async function loadModelProfiles(configPath: string): Promise<ModelProfiles> {
55
53
  let raw: string;
56
54
  try {
57
55
  raw = await readFile(configPath, "utf8");
@@ -67,13 +65,8 @@ export async function loadModelProfiles(
67
65
 
68
66
  export async function persistDefaultProfile(
69
67
  profileName: string,
70
- env: NodeJS.ProcessEnv = process.env,
68
+ configPath: string,
71
69
  ): Promise<void> {
72
- const configPath = modelsConfigPath(env);
73
- if (configPath === undefined) {
74
- return;
75
- }
76
-
77
70
  let raw: string;
78
71
  try {
79
72
  raw = await readFile(configPath, "utf8");
@@ -128,6 +121,11 @@ export function parseModelProfiles(raw: string, sourcePath: string): ModelProfil
128
121
  if (!isRecord(json)) {
129
122
  throw new Error(`Model profiles ${sourcePath} must be a JSON object.`);
130
123
  }
124
+ assertKnownKeys(
125
+ json,
126
+ MODEL_PROFILES_DOCUMENT_FIELDS.map((field) => field.name),
127
+ `Model profiles ${sourcePath}`,
128
+ );
131
129
 
132
130
  const defaultProfile = json.default;
133
131
  if (typeof defaultProfile !== "string" || defaultProfile.trim() === "") {
@@ -155,7 +153,15 @@ export function parseModelProfiles(raw: string, sourcePath: string): ModelProfil
155
153
  profiles.set(profileName, parseProfile(profileName, profileValue, sourcePath));
156
154
  }
157
155
 
158
- return { defaultProfile, profiles };
156
+ const memory =
157
+ json.memory === undefined
158
+ ? undefined
159
+ : parseMemoryConfig(json.memory, profiles, sourcePath);
160
+ return Object.freeze({
161
+ defaultProfile,
162
+ profiles,
163
+ ...(memory === undefined ? {} : { memory }),
164
+ });
159
165
  }
160
166
 
161
167
  export function resolveModelProfile(
@@ -238,45 +244,31 @@ function parseProfile(
238
244
 
239
245
  assertKnownKeys(
240
246
  value,
241
- [
242
- "model",
243
- "apiBase",
244
- "apiKey",
245
- "contextWindowTokens",
246
- "maxSupportedOutputTokens",
247
- "includeReasoningContent",
248
- "stream",
249
- "inputModalities",
250
- "tokenEstimator",
251
- ],
247
+ MODEL_PROFILE_FIELDS.map((field) => field.name),
252
248
  where,
253
249
  );
254
250
 
255
- const model = requireString(value.model, `${where}: "model"`);
256
- const apiBase = requireString(value.apiBase, `${where}: "apiBase"`);
257
- const apiKey = requireString(value.apiKey, `${where}: "apiKey"`);
251
+ const model = parseProfileString(value, "model", where);
252
+ const apiBase = parseProfileString(value, "apiBase", where);
253
+ const apiKey = parseProfileString(value, "apiKey", where);
258
254
 
259
- const contextWindowTokens = requirePositiveInteger(
260
- value.contextWindowTokens,
261
- `${where}: "contextWindowTokens"`,
255
+ const contextWindowTokens = parseProfilePositiveInteger(
256
+ value,
257
+ "contextWindowTokens",
258
+ where,
262
259
  );
263
- const maxSupportedOutputTokens = requirePositiveInteger(
264
- value.maxSupportedOutputTokens,
265
- `${where}: "maxSupportedOutputTokens"`,
260
+ const maxSupportedOutputTokens = parseProfilePositiveInteger(
261
+ value,
262
+ "maxSupportedOutputTokens",
263
+ where,
266
264
  );
267
265
 
268
- const includeReasoningContent =
269
- value.includeReasoningContent === undefined
270
- ? false
271
- : parseBoolean(
272
- value.includeReasoningContent,
273
- `${where}: "includeReasoningContent"`,
274
- );
275
-
276
- const stream =
277
- value.stream === undefined
278
- ? true
279
- : parseBoolean(value.stream, `${where}: "stream"`);
266
+ const includeReasoningContent = parseProfileBoolean(
267
+ value,
268
+ "includeReasoningContent",
269
+ where,
270
+ );
271
+ const stream = parseProfileBoolean(value, "stream", where);
280
272
 
281
273
  const inputModalities = parseInputModalities(
282
274
  value.inputModalities,
@@ -311,12 +303,63 @@ function parseProfile(
311
303
  });
312
304
  }
313
305
 
306
+ function parseMemoryConfig(
307
+ value: unknown,
308
+ profiles: ReadonlyMap<string, ModelProfile>,
309
+ sourcePath: string,
310
+ ): MemoryConfig {
311
+ const where = `Model profiles ${sourcePath}: "memory"`;
312
+ if (!isRecord(value)) {
313
+ throw new Error(`${where} must be an object.`);
314
+ }
315
+ assertKnownKeys(
316
+ value,
317
+ MEMORY_CONFIG_FIELDS.map((field) => field.name),
318
+ where,
319
+ );
320
+ const profile = requireString(value.profile, `${where}.profile`);
321
+ if (!profiles.has(profile)) {
322
+ throw unknownProfileNamesError(profile, [...profiles.keys()]);
323
+ }
324
+ const embedding = parseMemoryEmbedding(value.embedding, `${where}.embedding`);
325
+ return Object.freeze({ profile, embedding });
326
+ }
327
+
328
+ function parseMemoryEmbedding(value: unknown, where: string): MemoryEmbeddingConfig {
329
+ if (!isRecord(value)) {
330
+ throw new Error(`${where} must be an object.`);
331
+ }
332
+ assertKnownKeys(
333
+ value,
334
+ MEMORY_EMBEDDING_FIELDS.map((field) => field.name),
335
+ where,
336
+ );
337
+ const name = requireString(value.name, `${where}.name`);
338
+ if (value.kind !== "openai-compatible") {
339
+ throw new Error(`${where}.kind must be "openai-compatible".`);
340
+ }
341
+ const model = requireString(value.model, `${where}.model`);
342
+ const apiBase = requireString(value.apiBase, `${where}.apiBase`);
343
+ requireHttpUrl(apiBase, `${where}.apiBase`);
344
+ const apiKey = requireString(value.apiKey, `${where}.apiKey`);
345
+ const dimensions = requirePositiveInteger(value.dimensions, `${where}.dimensions`);
346
+ return Object.freeze({
347
+ name,
348
+ kind: "openai-compatible",
349
+ model,
350
+ apiBase,
351
+ apiKey,
352
+ dimensions,
353
+ });
354
+ }
355
+
314
356
  function parseInputModalities(
315
357
  value: unknown,
316
358
  name: string,
317
359
  ): readonly ModelInputModality[] {
318
360
  if (value === undefined) {
319
- return Object.freeze(["text"]);
361
+ const defaultValue = modelProfileField("inputModalities").defaultValue;
362
+ return defaultValue;
320
363
  }
321
364
  if (!Array.isArray(value) || value.length === 0) {
322
365
  throw new Error(`${name} must be a non-empty array.`);
@@ -345,32 +388,127 @@ function parseTokenEstimator(value: unknown, name: string): ModelTokenEstimatorP
345
388
  }
346
389
  assertKnownKeys(
347
390
  value,
348
- ["kind", "model", "apiBase", "apiKey", "timeoutMs", "maxRetries"],
391
+ MODEL_TOKEN_ESTIMATOR_FIELDS.map((field) => field.name),
349
392
  name,
350
393
  );
351
- if (value.kind !== "moonshot-estimate-token-count-v1") {
352
- throw new Error(`${name}.kind must be "moonshot-estimate-token-count-v1".`);
394
+ const kindField = tokenEstimatorField("kind");
395
+ if (value.kind !== kindField.literalValue) {
396
+ throw new Error(`${name}.kind must be ${JSON.stringify(kindField.literalValue)}.`);
397
+ }
398
+ const model = parseTokenEstimatorString(value, "model", name);
399
+ const apiBase = parseTokenEstimatorString(value, "apiBase", name);
400
+ const apiKey = parseTokenEstimatorString(value, "apiKey", name);
401
+ const timeoutField = tokenEstimatorField("timeoutMs");
402
+ if (timeoutField.valueKind !== "positive-integer") {
403
+ throw new Error("Token estimator timeoutMs contract kind is invalid.");
353
404
  }
354
- const model = requireString(value.model, `${name}.model`);
355
- const apiBase = requireString(value.apiBase, `${name}.apiBase`);
356
- const apiKey = requireString(value.apiKey, `${name}.apiKey`);
357
405
  const timeoutMs = requirePositiveInteger(value.timeoutMs, `${name}.timeoutMs`);
358
- if (timeoutMs < 1_000 || timeoutMs > 60_000) {
359
- throw new Error(`${name}.timeoutMs must be between 1000 and 60000.`);
406
+ if (
407
+ timeoutField.minimum === undefined ||
408
+ timeoutField.maximum === undefined ||
409
+ timeoutMs < timeoutField.minimum ||
410
+ timeoutMs > timeoutField.maximum
411
+ ) {
412
+ throw new Error(
413
+ `${name}.timeoutMs must be between ${timeoutField.minimum} and ${timeoutField.maximum}.`,
414
+ );
360
415
  }
361
- if (value.maxRetries !== 0) {
362
- throw new Error(`${name}.maxRetries must be 0.`);
416
+ const maxRetriesField = tokenEstimatorField("maxRetries");
417
+ if (value.maxRetries !== maxRetriesField.literalValue) {
418
+ throw new Error(`${name}.maxRetries must be ${maxRetriesField.literalValue}.`);
363
419
  }
364
420
  return Object.freeze({
365
- kind: value.kind,
421
+ kind: kindField.literalValue,
366
422
  model,
367
423
  apiBase,
368
424
  apiKey,
369
425
  timeoutMs,
370
- maxRetries: 0,
426
+ maxRetries: maxRetriesField.literalValue,
371
427
  });
372
428
  }
373
429
 
430
+ type ModelProfileFieldName = (typeof MODEL_PROFILE_FIELDS)[number]["name"];
431
+ type ModelTokenEstimatorFieldName =
432
+ (typeof MODEL_TOKEN_ESTIMATOR_FIELDS)[number]["name"];
433
+
434
+ function modelProfileField<Name extends ModelProfileFieldName>(
435
+ name: Name,
436
+ ): Extract<(typeof MODEL_PROFILE_FIELDS)[number], { readonly name: Name }> {
437
+ const field = MODEL_PROFILE_FIELDS.find((candidate) => candidate.name === name);
438
+ if (field === undefined) {
439
+ throw new Error(`Missing model profile field contract for ${name}.`);
440
+ }
441
+ return field as Extract<
442
+ (typeof MODEL_PROFILE_FIELDS)[number],
443
+ { readonly name: Name }
444
+ >;
445
+ }
446
+
447
+ function tokenEstimatorField<Name extends ModelTokenEstimatorFieldName>(
448
+ name: Name,
449
+ ): Extract<(typeof MODEL_TOKEN_ESTIMATOR_FIELDS)[number], { readonly name: Name }> {
450
+ const field = MODEL_TOKEN_ESTIMATOR_FIELDS.find(
451
+ (candidate) => candidate.name === name,
452
+ );
453
+ if (field === undefined) {
454
+ throw new Error(`Missing token estimator field contract for ${name}.`);
455
+ }
456
+ return field as Extract<
457
+ (typeof MODEL_TOKEN_ESTIMATOR_FIELDS)[number],
458
+ { readonly name: Name }
459
+ >;
460
+ }
461
+
462
+ function parseProfileString(
463
+ value: Record<string, unknown>,
464
+ name: "model" | "apiBase" | "apiKey",
465
+ where: string,
466
+ ): string {
467
+ const field = modelProfileField(name);
468
+ if (field.valueKind !== "non-empty-string") {
469
+ throw new Error(`Model profile field ${name} has an invalid contract kind.`);
470
+ }
471
+ return requireString(value[name], `${where}: ${JSON.stringify(name)}`);
472
+ }
473
+
474
+ function parseProfilePositiveInteger(
475
+ value: Record<string, unknown>,
476
+ name: "contextWindowTokens" | "maxSupportedOutputTokens",
477
+ where: string,
478
+ ): number {
479
+ const field = modelProfileField(name);
480
+ if (field.valueKind !== "positive-integer") {
481
+ throw new Error(`Model profile field ${name} has an invalid contract kind.`);
482
+ }
483
+ return requirePositiveInteger(value[name], `${where}: ${JSON.stringify(name)}`);
484
+ }
485
+
486
+ function parseProfileBoolean(
487
+ value: Record<string, unknown>,
488
+ name: "includeReasoningContent" | "stream",
489
+ where: string,
490
+ ): boolean {
491
+ const field = modelProfileField(name);
492
+ if (field.valueKind !== "boolean" || typeof field.defaultValue !== "boolean") {
493
+ throw new Error(`Model profile field ${name} has an invalid boolean contract.`);
494
+ }
495
+ return value[name] === undefined
496
+ ? field.defaultValue
497
+ : parseBoolean(value[name], `${where}: ${JSON.stringify(name)}`);
498
+ }
499
+
500
+ function parseTokenEstimatorString(
501
+ value: Record<string, unknown>,
502
+ name: "model" | "apiBase" | "apiKey",
503
+ where: string,
504
+ ): string {
505
+ const field = tokenEstimatorField(name);
506
+ if (field.valueKind !== "non-empty-string") {
507
+ throw new Error(`Token estimator field ${name} has an invalid contract kind.`);
508
+ }
509
+ return requireString(value[name], `${where}.${name}`);
510
+ }
511
+
374
512
  function assertKnownKeys(
375
513
  value: Record<string, unknown>,
376
514
  allowed: readonly string[],
@@ -401,6 +539,22 @@ function requirePositiveInteger(value: unknown, name: string): number {
401
539
  return value;
402
540
  }
403
541
 
542
+ function requireHttpUrl(value: string, name: string): void {
543
+ let parsed: URL;
544
+ try {
545
+ parsed = new URL(value);
546
+ } catch {
547
+ throw new Error(`${name} must be a valid HTTP(S) URL.`);
548
+ }
549
+ if (
550
+ (parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
551
+ parsed.username !== "" ||
552
+ parsed.password !== ""
553
+ ) {
554
+ throw new Error(`${name} must be a valid HTTP(S) URL.`);
555
+ }
556
+ }
557
+
404
558
  function parseBoolean(value: unknown, name: string): boolean {
405
559
  if (typeof value === "boolean") {
406
560
  return value;