modelmix 5.0.2 → 5.0.3

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 (37) hide show
  1. package/README.md +109 -7
  2. package/RLM_PLUGIN_SPEC.md +465 -0
  3. package/demo/gemini.js +3 -4
  4. package/demo/short.js +1 -1
  5. package/effort.js +2 -0
  6. package/index.d.ts +61 -1
  7. package/index.js +333 -45
  8. package/package.json +7 -4
  9. package/plugins/rlm/index.d.ts +194 -0
  10. package/plugins/rlm/index.js +25 -0
  11. package/plugins/rlm/lib/budget.js +153 -0
  12. package/plugins/rlm/lib/isolated-vm-sandbox.js +90 -0
  13. package/plugins/rlm/lib/markdown.js +156 -0
  14. package/plugins/rlm/lib/planner-prompt.js +137 -0
  15. package/plugins/rlm/lib/plugin.js +203 -0
  16. package/plugins/rlm/lib/runtime.js +146 -0
  17. package/plugins/rlm/lib/variable-descriptors.js +228 -0
  18. package/plugins/rlm/lib/worker-catalog.js +70 -0
  19. package/plugins/rlm/package.json +32 -0
  20. package/plugins/rlm/prompts/partials/processing-rules.md +8 -0
  21. package/plugins/rlm/prompts/planner.md +53 -0
  22. package/plugins/rlm/test/budget.test.js +86 -0
  23. package/plugins/rlm/test/fixtures/book.md +24 -0
  24. package/plugins/rlm/test/isolated-vm-sandbox.test.js +114 -0
  25. package/plugins/rlm/test/markdown.test.js +64 -0
  26. package/plugins/rlm/test/planner-template.test.js +140 -0
  27. package/plugins/rlm/test/plugin-contract.test.js +182 -0
  28. package/plugins/rlm/test/rlm-e2e.test.js +338 -0
  29. package/plugins/rlm/test/variable-descriptors.test.js +170 -0
  30. package/plugins/rlm/test/worker-catalog.test.js +104 -0
  31. package/pnpm-workspace.yaml +6 -0
  32. package/skills/modelmix/SKILL.md +22 -3
  33. package/test/effort.test.js +14 -1
  34. package/test/live.mcp.js +6 -6
  35. package/test/live.test.js +2 -2
  36. package/test/plugins.test.js +356 -0
  37. package/test/tokens.test.js +37 -5
@@ -0,0 +1,170 @@
1
+ const { expect } = require('chai');
2
+ const { describeVariables, plannerTemplateData } = require('..');
3
+
4
+ describe('RLM variable metadata', () => {
5
+ const workerManifest = {
6
+ fast: {
7
+ intelligence: 1,
8
+ cost: 1,
9
+ speed: 5,
10
+ description: 'Fast transformations'
11
+ }
12
+ };
13
+ const runtimeLimits = {
14
+ maxCalls: 20,
15
+ maxOutputBytes: 1024 * 1024,
16
+ maxGeneratedTokens: 10000,
17
+ maxWallTimeMs: 30000
18
+ };
19
+ const book = {
20
+ title: 'A hidden title that must not reach the planner',
21
+ chapters: [
22
+ {
23
+ heading: 'First hidden chapter',
24
+ content: 'First secret paragraph.\n\nSecond secret paragraph.',
25
+ tags: ['opening', 'setup']
26
+ },
27
+ {
28
+ heading: 'Second hidden chapter',
29
+ content: 'Árbol and emoji 😀.\n\nAnother concealed paragraph.',
30
+ tags: ['middle']
31
+ }
32
+ ]
33
+ };
34
+
35
+ it('describes strings, objects, and arrays without exposing their content', () => {
36
+ const manifest = describeVariables(book);
37
+ const title = manifest.descriptors.title;
38
+ const chapters = manifest.descriptors.chapters;
39
+
40
+ expect(manifest).to.include({
41
+ sizeBasis: 'serialized-json-utf8',
42
+ variables: 2,
43
+ estimatedBytes: Buffer.byteLength(JSON.stringify(book), 'utf8')
44
+ });
45
+ expect(title).to.include({
46
+ path: 'title',
47
+ type: 'string',
48
+ characters: Array.from(book.title).length,
49
+ utf16CodeUnits: book.title.length,
50
+ utf8Bytes: Buffer.byteLength(book.title, 'utf8'),
51
+ lines: 1,
52
+ paragraphs: 1
53
+ });
54
+ expect(chapters).to.include({
55
+ path: 'chapters',
56
+ type: 'array',
57
+ items: 2,
58
+ estimatedBytes: Buffer.byteLength(JSON.stringify(book.chapters), 'utf8')
59
+ });
60
+ expect(chapters.itemSize).to.deep.equal({
61
+ min: Math.min(...book.chapters.map(chapter => Buffer.byteLength(JSON.stringify(chapter), 'utf8'))),
62
+ max: Math.max(...book.chapters.map(chapter => Buffer.byteLength(JSON.stringify(chapter), 'utf8'))),
63
+ average: Number((book.chapters
64
+ .map(chapter => Buffer.byteLength(JSON.stringify(chapter), 'utf8'))
65
+ .reduce((sum, bytes) => sum + bytes, 0) / 2).toFixed(2)),
66
+ total: book.chapters
67
+ .map(chapter => Buffer.byteLength(JSON.stringify(chapter), 'utf8'))
68
+ .reduce((sum, bytes) => sum + bytes, 0)
69
+ });
70
+ expect(chapters.itemShape.types).to.deep.equal({ object: 2 });
71
+ expect(chapters.itemShape.properties.content).to.deep.include({
72
+ present: 2,
73
+ missing: 0,
74
+ types: { string: 2 }
75
+ });
76
+ expect(chapters.itemShape.properties.content.stringSize.paragraphs).to.deep.equal({
77
+ min: 2,
78
+ max: 2,
79
+ average: 2,
80
+ total: 4
81
+ });
82
+
83
+ const serializedManifest = JSON.stringify(manifest);
84
+ expect(serializedManifest).to.not.include(book.title);
85
+ for (const chapter of book.chapters) {
86
+ expect(serializedManifest).to.not.include(chapter.heading);
87
+ expect(serializedManifest).to.not.include(chapter.content);
88
+ for (const tag of chapter.tags) expect(serializedManifest).to.not.include(tag);
89
+ }
90
+ });
91
+
92
+ it('keeps exact aggregate sizes for large arrays without listing their items', () => {
93
+ const values = Array.from({ length: 100000 }, (_, index) => `row-${index}`);
94
+ const descriptor = describeVariables({ values }).descriptors.values;
95
+
96
+ expect(descriptor.items).to.equal(values.length);
97
+ expect(descriptor.estimatedBytes).to.equal(Buffer.byteLength(JSON.stringify(values), 'utf8'));
98
+ expect(descriptor).to.not.have.property('children');
99
+ expect(JSON.stringify(descriptor)).to.not.include('row-99999');
100
+ });
101
+
102
+ it('rejects values that cannot safely enter the sandbox environment', () => {
103
+ const circular = {};
104
+ circular.self = circular;
105
+
106
+ expect(() => describeVariables({ circular })).to.throw('circular reference');
107
+ expect(() => describeVariables({ callback() {} })).to.throw('unsupported type function');
108
+ expect(() => describeVariables({ invalid: Infinity })).to.throw('finite numbers');
109
+ expect(() => describeVariables({ date: new Date() })).to.throw('plain objects');
110
+ });
111
+
112
+ it('adds limits and actionable partition guidance to the planner prompt', () => {
113
+ const templateData = plannerTemplateData({
114
+ variables: book,
115
+ limits: {
116
+ ...runtimeLimits,
117
+ maxQueryBytes: 32,
118
+ sandboxMemoryBytes: 64 * 1024 * 1024,
119
+ maxConcurrentQueries: 4
120
+ },
121
+ workerManifest
122
+ });
123
+
124
+ expect(templateData.variableManifest).to.include('"items": 2');
125
+ expect(templateData.processingLimits).to.include('"maxQueryBytes": 32');
126
+ expect(templateData.processingLimits).to.include('"sandboxMemoryBytes": 67108864');
127
+ expect(templateData.processingLimits).to.include('"maxConcurrentQueries": 4');
128
+ expect(templateData.planningHints).to.include('"strategy": "split-oversized-items-semantically"');
129
+ expect(templateData.planningHints).to.include('"oversizedStringFields"');
130
+ expect(templateData.planningHints).to.include('"content"');
131
+ const serializedTemplateData = JSON.stringify(templateData);
132
+ expect(serializedTemplateData).to.not.include(book.title);
133
+ for (const chapter of book.chapters) {
134
+ expect(serializedTemplateData).to.not.include(chapter.heading);
135
+ expect(serializedTemplateData).to.not.include(chapter.content);
136
+ }
137
+ });
138
+
139
+ it('distinguishes direct values from arrays that should be batched', () => {
140
+ const templateData = plannerTemplateData({
141
+ variables: {
142
+ instruction: 'short',
143
+ paragraphs: Array.from({ length: 20 }, () => 'small paragraph')
144
+ },
145
+ limits: {
146
+ ...runtimeLimits,
147
+ maxQueryBytes: 80,
148
+ sandboxMemoryBytes: 1024 * 1024,
149
+ maxConcurrentQueries: 2
150
+ },
151
+ workerManifest
152
+ });
153
+
154
+ expect(templateData.planningHints).to.include('"path": "instruction"');
155
+ expect(templateData.planningHints).to.include('"strategy": "direct"');
156
+ expect(templateData.planningHints).to.include('"path": "paragraphs"');
157
+ expect(templateData.planningHints).to.include('"strategy": "batch-array-items"');
158
+ expect(templateData.planningHints).to.match(/"suggestedMaxItemsPerQuery": [1-9]\d*/);
159
+ });
160
+
161
+ it('requires explicit planning limits instead of silent defaults', () => {
162
+ expect(() => plannerTemplateData({ variables: {}, limits: {}, workerManifest }))
163
+ .to.throw('limits.maxQueryBytes');
164
+ expect(() => plannerTemplateData({
165
+ variables: {},
166
+ limits: { maxQueryBytes: 100, sandboxMemoryBytes: 1000 },
167
+ workerManifest
168
+ })).to.throw('limits.maxConcurrentQueries');
169
+ });
170
+ });
@@ -0,0 +1,104 @@
1
+ const { expect } = require('chai');
2
+ const { MixCustom, ModelMix } = require('../../../index.js');
3
+ const { createWorkerCatalog } = require('..');
4
+
5
+ function workerModel(secret = 'worker-secret') {
6
+ return ModelMix.new().attach('worker-model', new MixCustom({
7
+ config: { apiKey: secret, url: 'https://private-provider.invalid' }
8
+ }));
9
+ }
10
+
11
+ describe('RLM worker catalog', () => {
12
+ it('exposes decision metadata without models, providers, or credentials', () => {
13
+ const model = workerModel();
14
+ const catalog = createWorkerCatalog({
15
+ expert: {
16
+ model,
17
+ intelligence: 5,
18
+ cost: 4,
19
+ speed: 2,
20
+ description: 'Complex synthesis'
21
+ },
22
+ fast: {
23
+ model: workerModel('another-secret'),
24
+ intelligence: 1,
25
+ cost: 1,
26
+ speed: 5,
27
+ description: 'Extraction and classification'
28
+ }
29
+ });
30
+
31
+ expect(catalog.get('expert')).to.equal(model);
32
+ expect(catalog.manifest).to.deep.equal({
33
+ expert: {
34
+ intelligence: 5,
35
+ cost: 4,
36
+ speed: 2,
37
+ description: 'Complex synthesis'
38
+ },
39
+ fast: {
40
+ intelligence: 1,
41
+ cost: 1,
42
+ speed: 5,
43
+ description: 'Extraction and classification'
44
+ }
45
+ });
46
+ const serialized = JSON.stringify(catalog.manifest);
47
+ expect(serialized).to.not.include('worker-secret');
48
+ expect(serialized).to.not.include('private-provider');
49
+ expect(serialized).to.not.include('models');
50
+ });
51
+
52
+ it('rejects invalid workers and unknown selections', () => {
53
+ expect(() => createWorkerCatalog({})).to.throw('non-empty');
54
+ expect(() => createWorkerCatalog({
55
+ invalid: {
56
+ model: {},
57
+ intelligence: 1,
58
+ cost: 1,
59
+ speed: 1,
60
+ description: 'Invalid model'
61
+ }
62
+ })).to.throw('ModelMix instance');
63
+ const catalog = createWorkerCatalog({
64
+ valid: {
65
+ model: workerModel(),
66
+ intelligence: 1,
67
+ cost: 1,
68
+ speed: 1,
69
+ description: 'Valid worker'
70
+ }
71
+ });
72
+ expect(() => catalog.get('missing')).to.throw('Unknown RLM worker');
73
+ });
74
+
75
+ it('represents the inherited parent chain without exposing a model object', () => {
76
+ const catalog = createWorkerCatalog({
77
+ parent: {
78
+ useParent: true,
79
+ intelligence: 3,
80
+ cost: 2,
81
+ speed: 3,
82
+ description: 'Use the current ModelMix chain'
83
+ }
84
+ });
85
+
86
+ expect(catalog.get('parent')).to.equal(undefined);
87
+ expect(catalog.manifest.parent).to.deep.equal({
88
+ intelligence: 3,
89
+ cost: 2,
90
+ speed: 3,
91
+ description: 'Use the current ModelMix chain'
92
+ });
93
+ expect(() => createWorkerCatalog({
94
+ invalid: {
95
+ model: workerModel(),
96
+ useParent: true,
97
+ intelligence: 1,
98
+ cost: 1,
99
+ speed: 1,
100
+ description: 'Ambiguous worker'
101
+ }
102
+ })).to.throw('exactly one of model or useParent');
103
+ });
104
+ });
@@ -1,3 +1,9 @@
1
+ packages:
2
+ - plugins/*
3
+
4
+ allowBuilds:
5
+ isolated-vm: true
6
+
1
7
  minimumReleaseAgeExclude:
2
8
  - ws@8.21.0
3
9
  - hono@4.12.25
@@ -92,6 +92,24 @@ const model = ModelMix.new()
92
92
 
93
93
  If `sonnet46` fails, it automatically tries `gpt52`, then `gemini3flash`.
94
94
 
95
+ ### Instance plugins
96
+
97
+ Register middleware with `.use({ name, execute })`. Plugins are scoped to the instance, run in registration order, and receive the rendered provider-neutral request. They may edit `context.request`, call `next()`, or return a complete ModelMix result.
98
+
99
+ ```javascript
100
+ model.use({
101
+ name: 'metrics',
102
+ async execute(context, next) {
103
+ const result = await next();
104
+ return { ...result, executionId: context.execution.executionId };
105
+ }
106
+ });
107
+ ```
108
+
109
+ `context.invoke()` starts a child without conversation history. Its `plugins` policy is `'inherit'`, `'none'`, `{ include: [...] }`, or `{ exclude: [...] }`. Passing `model` routes the child through another ModelMix worker chain while preserving execution-tree metadata. Child invocations may pass `assign` with either `system` or `systemFile`; `systemFile` uses the ordinary ModelMix EJS renderer and relative includes.
110
+
111
+ The optional `@modelmix/rlm` package is a separate workspace/npm package for recursive processing of large structured inputs. Pass Markdown through `documents: { name: { format: 'markdown', content } }`, register named ModelMix worker chains, and provide every runtime limit explicitly. A worker uses either `model: anotherModelMixInstance` or `useParent: true`. Its planner sees content-free variable size/shape metadata, while document values and generated orchestration code stay inside an `isolated-vm` sandbox. RLM planner prompts are Markdown files rendered with the normal child `assign` plus `systemFile` path.
112
+
95
113
  ### Unified effort
96
114
 
97
115
  Provider-agnostic reasoning intensity. **Not** an `options` field — use `config.effort` or `.effort(n)`.
@@ -114,7 +132,7 @@ ModelMix.new({ config: { effort: 80 } })
114
132
  | DeepSeek V4 | off | `low`↑ | `high`↑ | `high`↑ | `max`↑ | — |
115
133
  | MiniMax M3 | off | adaptive | adaptive | adaptive | adaptive | adaptive |
116
134
 
117
- \* Gemini bands: 0–24 / 25–49 / 50–74 / 75–100. DeepSeek `↑` = thinking on; `off` = thinking disabled. MiniMax `off`/`adaptive` = `thinking.disabled` / `thinking.type=adaptive`. Gemini 2.5 maps 0–100 to `thinkingBudget`. Anthropic: adaptive + `output_config.effort` on Claude 5 / Fable / Opus 4.6+ / Sonnet 4.6+; Sonnet 4.5 / Haiku 4.5 use `thinking.type=enabled` + `budget_tokens`. Grok 4.6 maps 0–39 / 40–59 / 60–79 / 80–100 to `low` / `medium` / `high` / `xhigh`; without effort it uses native `high`. `-1` = adaptive/dynamic when available, else no-op. Levels clamp per model. Former `*think()` methods are removed — use `.effort(n).<model>()`. Kimi: `kimiK25()` / `kimiK26()`. Grok 4.20: `.grok420()` non-reasoning; `.effort(20+|-1).grok420()` selects reasoning.
135
+ \* Gemini bands: 0–24 / 25–49 / 50–74 / 75–100. Gemini 3.7 Flash supports only `low` / `medium` / `high`, so the first two bands clamp to `low`; `-1` keeps its native `medium` default. DeepSeek `↑` = thinking on; `off` = thinking disabled. MiniMax `off`/`adaptive` = `thinking.disabled` / `thinking.type=adaptive`. Gemini 2.5 maps 0–100 to `thinkingBudget`. Anthropic: adaptive + `output_config.effort` on Claude 5 / Fable / Opus 4.6+ / Sonnet 4.6+; Sonnet 4.5 / Haiku 4.5 use `thinking.type=enabled` + `budget_tokens`. Grok 4.6 maps 0–39 / 40–59 / 60–79 / 80–100 to `low` / `medium` / `high` / `xhigh`; without effort it uses native `high`. `-1` = adaptive/dynamic when available, else no-op. Levels clamp per model. Former `*think()` methods are removed — use `.effort(n).<model>()`. Kimi: `kimiK25()` / `kimiK26()`. Grok 4.20: `.grok420()` non-reasoning; `.effort(20+|-1).grok420()` selects reasoning.
118
136
 
119
137
  ## Available Model Shorthands
120
138
 
@@ -127,7 +145,7 @@ ModelMix.new({ config: { effort: 80 } })
127
145
  Use `.effort(n)` (or `config.effort`) to enable Anthropic thinking — e.g. `.effort(100).opus50()`. `fable5()` and `opus5()` remain available as compatibility aliases.
128
146
 
129
147
  ### Google
130
- `gemini3pro()` `gemini3flash()` `gemini36flash()` `gemini35flash()` `gemini35flashLite()` `gemini31flashLite()` `gemini25pro()` `gemini25flash()`
148
+ `gemini3pro()` `gemini3flash()` `gemini37flash()` `gemini36flash()` `gemini35flash()` `gemini35flashLite()` `gemini31flashLite()` `gemini25pro()` `gemini25flash()`
131
149
 
132
150
  ### Grok
133
151
  `grok46()` `grok45()` `grok43()` `grok420multiAgent()` `grok420()`
@@ -302,7 +320,7 @@ const model = ModelMix.new().gpt5mini().addText("Hello!");
302
320
  const text = await model.message();
303
321
  console.log(model.lastRaw.tokens);
304
322
  // {
305
- // input: 1200, output: 50, total: 1250,
323
+ // input: 1200, output: 50, thinking: 0, total: 1250,
306
324
  // cached: 1024, cacheWrite: 0, uncachedInput: 176,
307
325
  // cacheWrite5m: 0, cacheWrite1h: 0,
308
326
  // cacheHitRate: 0.8533, cacheSavings: 0.00018432,
@@ -586,6 +604,7 @@ const model = ModelMix.new({
586
604
  | `.addTools([{tool, callback}])` | `this` | Register multiple tools |
587
605
  | `.removeTool(name)` | `this` | Remove a tool |
588
606
  | `.listTools()` | `{local, mcp}` | List registered tools |
607
+ | `.use(plugin)` | `this` | Register instance-scoped execution middleware |
589
608
  | `.new()` | `ModelMix` | Clone instance sharing models |
590
609
  | `.attach(key, provider)` | `this` | Attach custom provider |
591
610
 
@@ -121,6 +121,19 @@ describe('Unified effort scale', () => {
121
121
  });
122
122
  });
123
123
 
124
+ it('clamps Gemini 3.7 Flash to low, medium, and high', () => {
125
+ expect(mapEffort('google', 0, 'gemini-3.7-flash')).to.deep.equal({
126
+ thinkingConfig: { thinkingLevel: 'low' }
127
+ });
128
+ expect(mapEffort('google', 50, 'gemini-3.7-flash')).to.deep.equal({
129
+ thinkingConfig: { thinkingLevel: 'medium' }
130
+ });
131
+ expect(mapEffort('google', 100, 'gemini-3.7-flash')).to.deep.equal({
132
+ thinkingConfig: { thinkingLevel: 'high' }
133
+ });
134
+ expect(mapEffort('google', -1, 'gemini-3.7-flash')).to.equal(null);
135
+ });
136
+
124
137
  it('clamps Gemini levels for models with fewer steps', () => {
125
138
  expect(mapEffort('google', 10, 'gemini-3-pro-preview')).to.deep.equal({
126
139
  thinkingConfig: { thinkingLevel: 'low' }
@@ -384,7 +397,7 @@ describe('Unified effort scale', () => {
384
397
  await google.create({
385
398
  config: { system: 'sys' },
386
399
  options: {
387
- model: 'gemini-3.6-flash',
400
+ model: 'gemini-3.7-flash',
388
401
  max_tokens: 100,
389
402
  messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
390
403
  thinkingConfig: { thinkingLevel: 'low' }
package/test/live.mcp.js CHANGED
@@ -181,8 +181,8 @@ describe('Live MCP Integration Tests', function () {
181
181
  }
182
182
  });
183
183
 
184
- it('should use custom MCP tools with Gemini 3 Flash', async function () {
185
- const model = ModelMix.new(setup).gemini3flash();
184
+ it('should use custom MCP tools with Gemini 3.7 Flash', async function () {
185
+ const model = ModelMix.new(setup).gemini37flash();
186
186
 
187
187
  // Add password generator tool
188
188
  model.addTool({
@@ -220,7 +220,7 @@ describe('Live MCP Integration Tests', function () {
220
220
  model.addText('Generate a secure password of 16 characters with symbols.');
221
221
 
222
222
  const response = await model.message();
223
- console.log(`Gemini 3 Flash with MCP tools: ${response}`);
223
+ console.log(`Gemini 3.7 Flash with MCP tools: ${response}`);
224
224
 
225
225
  expect(response).to.be.a('string');
226
226
  // Check password is mentioned and a generated password string is present
@@ -407,8 +407,8 @@ describe('Live MCP Integration Tests', function () {
407
407
  expect(result.factorial_result).to.equal(120);
408
408
  });
409
409
 
410
- it('should use MCP tools with JSON output using Gemini 3 Flash', async function () {
411
- const model = ModelMix.new(setup).gemini3flash();
410
+ it('should use MCP tools with JSON output using Gemini 3.7 Flash', async function () {
411
+ const model = ModelMix.new(setup).gemini37flash();
412
412
 
413
413
  // Add system info tool
414
414
  model.addTool({
@@ -447,7 +447,7 @@ describe('Live MCP Integration Tests', function () {
447
447
  generated_at: ""
448
448
  });
449
449
 
450
- console.log(`Gemini 3 Flash with MCP tools JSON result:`, result);
450
+ console.log(`Gemini 3.7 Flash with MCP tools JSON result:`, result);
451
451
 
452
452
  expect(result).to.be.an('object');
453
453
  expect(result.timestamp).to.be.a('number');
package/test/live.test.js CHANGED
@@ -59,7 +59,7 @@ describe('Live Integration Tests', function () {
59
59
  });
60
60
 
61
61
  it('should process images with Google Gemini', async function () {
62
- const model = ModelMix.new(setup).gemini3flash();
62
+ const model = ModelMix.new(setup).gemini37flash();
63
63
 
64
64
  model.addImageFromUrl(blueSquareBase64)
65
65
  .addText('What color is this image? Answer in one word only.');
@@ -120,7 +120,7 @@ describe('Live Integration Tests', function () {
120
120
  });
121
121
 
122
122
  it('should return structured JSON with Google Gemini', async function () {
123
- const model = ModelMix.new(setup).gemini3flash();
123
+ const model = ModelMix.new(setup).gemini37flash();
124
124
 
125
125
  model.addText('Generate information about a fictional city.');
126
126