kerzgrr commited on
Commit
90e4c64
·
verified ·
1 Parent(s): 5cc937b

Upload Tercet-R-1.1 (stage-3 EMA @ step 5300)

Browse files
README.md ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - en
5
+ tags:
6
+ - text-generation
7
+ - causal-lm
8
+ - pytorch
9
+ - sft
10
+ - instruction-tuned
11
+ - chat
12
+ - reasoning
13
+ - tool-use
14
+ - hybrid
15
+ - gated-deltanet
16
+ - gqa
17
+ - tercet
18
+ pipeline_tag: text-generation
19
+ library_name: tiny_gdn
20
+ datasets:
21
+ - HuggingFaceFW/fineweb-edu
22
+ - HuggingFaceTB/smoltalk2
23
+ - HuggingFaceTB/smoltalk
24
+ - nvidia/Llama-Nemotron-Post-Training-Dataset
25
+ - nvidia/Nemotron-Post-Training-Dataset-v1
26
+ - nvidia/Nemotron-Agentic-v1
27
+ - nvidia/Nemotron-SFT-Agentic-v2
28
+ - nvidia/Nemotron-SFT-Instruction-Following-Chat-v2
29
+ - nvidia/Nemotron-Instruction-Following-Chat-v1
30
+ - nvidia/Nemotron-Cascade-2-SFT-Data
31
+ - Agent-Ark/Toucan-1.5M
32
+ - interstellarninja/hermes_reasoning_tool_use
33
+ - NousResearch/Hermes-3-Dataset
34
+ - allenai/WildChat-4.8M
35
+ - open-thoughts/OpenThoughts3-1.2M
36
+ - teknium/OpenHermes-2.5
37
+ - open-r1/Mixture-of-Thoughts
38
+ - CohereLabs/aya_dataset
39
+ - NousResearch/hermes-function-calling-v1
40
+ - simplescaling/s1K-1.1
41
+ - allenai/tulu-3-sft-personas-instruction-following
42
+ - Salesforce/xlam-function-calling-60k
43
+ - THUDM/LongAlign-10k
44
+ base_model: kerzgrr/Tercet-R-1.0
45
+ ---
46
+
47
+ <div align="center">
48
+
49
+ # Tercet-R-1.1
50
+
51
+ ### Reasoning + tool-call chat model (~502M) — latest Tercet-R snapshot
52
+
53
+ [![Model](https://img.shields.io/badge/Model-~502M_params-blue)](.)
54
+ [![Stage](https://img.shields.io/badge/Stage-SFT_stage--3-green.svg)](.)
55
+ [![License](https://img.shields.io/badge/License-Apache_2.0-green.svg)](LICENSE)
56
+ [![Demo](https://img.shields.io/badge/Space-tercet--r--1.1--demo-indigo.svg)](https://huggingface.co/spaces/kerzgrr/tercet-r-1.1-demo)
57
+
58
+ *A ~502M hybrid GDN-2 + GQA model, continued on multi-turn tools and instruction following*
59
+
60
+ </div>
61
+
62
+ ---
63
+
64
+ ## What this is
65
+
66
+ **Tercet-R-1.1** is the latest public reasoning / tool-use checkpoint in the Tercet-R line.
67
+
68
+ - Previous public snapshot: [`kerzgrr/Tercet-R-1.0`](https://huggingface.co/kerzgrr/Tercet-R-1.0)
69
+ - Base (pretrain): [`kerzgrr/Tercet-base`](https://huggingface.co/kerzgrr/Tercet-base)
70
+ - Live demo: [`kerzgrr/tercet-r-1.1-demo`](https://huggingface.co/spaces/kerzgrr/tercet-r-1.1-demo)
71
+ - Hub weights are the **EMA** snapshot in **bfloat16**
72
+
73
+ This upload is the latest **complete** stage-3 SFT checkpoint (optimizer step **5,300**). The stage-3 run is still in progress toward a much longer schedule; 1.1 is the snapshot that existed when this repo was published.
74
+
75
+ Initialization was the instruct-run EMA at step 5,000 (`tercet-r-instruct`), not the public 1.0 file (that release packaged instruct step 4,500). The chat contract is unchanged.
76
+
77
+ ---
78
+
79
+ ## Chat contract
80
+
81
+ ### Thinking
82
+
83
+ Each assistant turn is prefixed with a **zero-loss control token**:
84
+
85
+ | Mode | Prefix | Typical body |
86
+ |------|--------|----------------|
87
+ | think | `<\|think\|>\n` | `<think>…</think>` then the answer |
88
+ | no-think | `<\|no_think\|>\n` | answer only |
89
+
90
+ `inference.py` streams the `<think>` region live (dim yellow) and hides the control tokens.
91
+
92
+ ### Tool calls (SmolTalk JSON)
93
+
94
+ ```
95
+ <tool_call>
96
+ {"name": "web-search", "arguments": {"query": "…"}}
97
+ </tool_call>
98
+ ```
99
+
100
+ Stage-3 also saw `stateful_python_code_exec` leftovers from earlier Cascade-2 SFT. `inference.py` auto-runs:
101
+
102
+ | Built-in | Tool name | Observation |
103
+ |----------|-----------|-------------|
104
+ | `--tools web-search` | `web-search` | Tavily-shaped JSON (Tavily if `TAVILY_API_KEY` is set, else DuckDuckGo + Wikipedia) |
105
+ | `--tools python` | `stateful_python_code_exec` | Jupyter-style stdout / last value from a restricted interpreter |
106
+ | `--tools calculator` | `calculator` | Numeric result of a math expression |
107
+
108
+ ### Tool results
109
+
110
+ Each observation is a `tool` (or `user`) turn prefixed with:
111
+
112
+ ```
113
+ <|tool_response|>
114
+ {observation}
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Install & run
120
+
121
+ ```bash
122
+ pip install torch safetensors tokenizers huggingface_hub
123
+ hf download kerzgrr/Tercet-R-1.1 inference.py --local-dir .
124
+ python inference.py
125
+ ```
126
+
127
+ ```bash
128
+ python inference.py --prompt "What is the capital of France?"
129
+ python inference.py --tools web-search,python,calculator
130
+ python inference.py --no-think --prompt "Reply in one sentence."
131
+ ```
132
+
133
+ `inference.py` auto-downloads weights / tokenizer / `tiny_gdn/` and auto-installs pinned `flash-linear-attention`. Git is required on `PATH`.
134
+
135
+ | Flag | Default | Description |
136
+ |------|---------|-------------|
137
+ | `--prompt` | — | One-shot user message |
138
+ | `--system` | — | System prompt, used verbatim |
139
+ | `--think` / `--no-think` | think | Assistant control prefix |
140
+ | `--tools` | — | Built-ins: `web-search`, `python`, `calculator` (comma-separated) |
141
+ | `--temperature` | `0.7` | Sampling temperature |
142
+ | `--max-new-tokens` | `4096` | Max generation length |
143
+ | `--device` | `cuda` if available | `cuda` / `cpu` |
144
+
145
+ Interactive commands: `/think` `/no_think` `/system …` `/reset` `/exit`.
146
+
147
+ ---
148
+
149
+ ## Model architecture
150
+
151
+ Same TinyGDN hybrid as [`Tercet-base`](https://huggingface.co/kerzgrr/Tercet-base) (501,635,264 parameters):
152
+
153
+ | | |
154
+ |--|--|
155
+ | **Layers** | 32 (GDN-2 ×3 + GQA every 4th) |
156
+ | **Hidden** | 1,024 |
157
+ | **MLP** | SwiGLU 2,624 |
158
+ | **Attention** | 8 Q / 2 KV, head dim 128, partial RoPE |
159
+ | **Linear** | Gated DeltaNet-2, 8 heads × 128 |
160
+ | **Vocab** | 49,152 BPE |
161
+ | **Context** | 16,384 |
162
+
163
+ ---
164
+
165
+ ## Training
166
+
167
+ | Stage | Details |
168
+ |-------|---------|
169
+ | **Base** | [HuggingFaceFW/fineweb-edu](https://huggingface.co/datasets/HuggingFaceFW/fineweb-edu) → [`Tercet-base`](https://huggingface.co/kerzgrr/Tercet-base) |
170
+ | **Mid + instruct SFT** | [HuggingFaceTB/smoltalk2](https://huggingface.co/datasets/HuggingFaceTB/smoltalk2) Mid / SFT → instruct EMA @ step 5,000 |
171
+ | **Stage-3 SFT** | Multi-turn mix: Nemotron PTD v1, Agentic v1/v2 (including `web-search`), IF-Chat v1/v2, Cascade-2 instruction following, Toucan 1.5M, Hermes-3, Hermes reasoning tool-use, WildChat-4.8M. Seq 16,384, AdamW 3×10⁻⁵, 27.5 hours so far, 602M training tokens |
172
+ | **Checkpoint** | optimizer step 5,300 (latest complete stage-3 snapshot) |
173
+ | **Weights** | EMA (this repo's `model.safetensors`) |
174
+ | **Val loss (EMA)** | 2.027 (ppl 7.59) on the stage-3 holdout — a harder mix than the instruct-only 1.0 val set |
175
+
176
+ Stage-3 does **not** re-include Cascade-2 `math/math_tool.jsonl`. The restricted Python tool in `inference.py` is there so leftover `stateful_python_code_exec` calls still compute instead of failing open.
177
+
178
+ ---
179
+
180
+ ## Limitations
181
+
182
+ - **In-progress run**: this is a mid-stage-3 snapshot, not a finished 1.1 train
183
+ - **Scale**: ~502M is a research / edge model, not a frontier system
184
+ - Requires `flash-linear-attention`; not GGUF / llama.cpp compatible today
185
+ - The Python tool is a restricted interpreter (math-oriented imports only)
186
+
187
+ ---
188
+
189
+ ## Model family
190
+
191
+ | Model | Stage | Hub |
192
+ |-------|-------|-----|
193
+ | Tercet-base | Pretrain | [`kerzgrr/Tercet-base`](https://huggingface.co/kerzgrr/Tercet-base) |
194
+ | Tercet | SFT chat | [`kerzgrr/Tercet`](https://huggingface.co/kerzgrr/Tercet) |
195
+ | Tercet-R-1.0 | SFT reasoning + tools | [`kerzgrr/Tercet-R-1.0`](https://huggingface.co/kerzgrr/Tercet-R-1.0) |
196
+ | **Tercet-R-1.1** | **Stage-3 SFT (latest)** | **this repo** |
197
+ | Demo | ZeroGPU Space | [`kerzgrr/tercet-r-1.1-demo`](https://huggingface.co/spaces/kerzgrr/tercet-r-1.1-demo) |
198
+
199
+ ---
200
+
201
+ ## Citation
202
+
203
+ ```bibtex
204
+ @misc{tercetr11,
205
+ title={Tercet-R-1.1: A 502M Hybrid GDN-2 + GQA Reasoning Model},
206
+ author={kerzgrr},
207
+ year={2026},
208
+ url={https://huggingface.co/kerzgrr/Tercet-R-1.1}
209
+ }
210
+ ```
211
+
212
+ ---
213
+
214
+ <div align="center">
215
+
216
+ *R is for reasoning.*
217
+
218
+ </div>
chat_template.jinja ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {%- set ns = namespace(xml_tools=none, tools_emitted=false) -%}
2
+ {%- if xml_tools is defined and xml_tools -%}
3
+ {%- set ns.xml_tools = xml_tools -%}
4
+ {%- elif tools is defined and tools -%}
5
+ {%- set ns.xml_tools = tools -%}
6
+ {%- endif -%}
7
+ {%- set tools_preamble = 'You may call one or more functions to assist with the user query.\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>\n' -%}
8
+ {%- set tools_epilogue = '</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n{"name": <function-name>, "arguments": <args-json-object>}\n</tool_call>' -%}
9
+ {%- for message in messages -%}
10
+ {%- if loop.first -%}{{- bos_token -}}{%- endif -%}
11
+ {%- if loop.first and ns.xml_tools and message['role'] != 'system' -%}
12
+ {{- '<|im_start|>system\n' + tools_preamble -}}
13
+ {%- for tool in ns.xml_tools -%}
14
+ {%- if tool is string -%}{{- (tool | replace('<tools>', '') | replace('</tools>', '') | trim) + '\n' -}}
15
+ {%- else -%}{{- tool | tojson + '\n' -}}
16
+ {%- endif -%}
17
+ {%- endfor -%}
18
+ {{- tools_epilogue + '<|im_end|>\n' -}}
19
+ {%- set ns.tools_emitted = true -%}
20
+ {%- endif -%}
21
+ {%- set raw_role = message['role'] -%}
22
+ {%- set role = 'user' if raw_role == 'tool' or raw_role == 'function' else raw_role -%}
23
+ {%- set content_text = namespace(value='') -%}
24
+ {%- if message['content'] is string -%}
25
+ {%- set content_text.value = message['content'] -%}
26
+ {%- elif message['content'] is iterable -%}
27
+ {%- for item in message['content'] -%}
28
+ {%- if item['type'] == 'text' -%}{%- set content_text.value = content_text.value + item['text'] -%}{%- endif -%}
29
+ {%- endfor -%}
30
+ {%- endif -%}
31
+ {%- set is_tool = raw_role == 'tool' or raw_role == 'function' -%}
32
+ {%- set prev_is_tool = loop.previtem is defined and (loop.previtem['role'] == 'tool' or loop.previtem['role'] == 'function') -%}
33
+ {%- set next_is_tool = loop.nextitem is defined and (loop.nextitem['role'] == 'tool' or loop.nextitem['role'] == 'function') -%}
34
+ {%- if raw_role == 'system' and not (content_text.value | trim) and not (ns.xml_tools and not ns.tools_emitted) -%}
35
+ {%- else -%}
36
+ {%- if is_tool and prev_is_tool -%}
37
+ {{- '\n' -}}
38
+ {%- else -%}
39
+ {{- '<|im_start|>' + role + '\n' -}}
40
+ {%- endif -%}
41
+ {%- if raw_role == 'assistant' and enable_thinking is defined -%}
42
+ {{- ('<|think|>\n' if enable_thinking else '<|no_think|>\n') -}}
43
+ {%- endif -%}
44
+ {%- if is_tool -%}
45
+ {{- '<|tool_response|>\n' + (content_text.value | replace('<tool_response>', '') | replace('</tool_response>', '') | trim) -}}
46
+ {%- elif raw_role == 'system' -%}
47
+ {{- content_text.value | replace('/system_override', '') | replace('/no_think', '') | replace('/think', '') | trim -}}
48
+ {%- else -%}
49
+ {{- content_text.value -}}
50
+ {%- endif -%}
51
+ {%- if raw_role == 'system' and ns.xml_tools and not ns.tools_emitted and '<tools>' not in content_text.value -%}
52
+ {%- if content_text.value | trim -%}{{- '\n\n' -}}{%- endif -%}
53
+ {{- tools_preamble -}}
54
+ {%- for tool in ns.xml_tools -%}
55
+ {%- if tool is string -%}{{- (tool | replace('<tools>', '') | replace('</tools>', '') | trim) + '\n' -}}
56
+ {%- else -%}{{- tool | tojson + '\n' -}}
57
+ {%- endif -%}
58
+ {%- endfor -%}
59
+ {{- tools_epilogue -}}
60
+ {%- set ns.tools_emitted = true -%}
61
+ {%- endif -%}
62
+ {%- if raw_role == 'assistant' and message['tool_calls'] is defined and message['tool_calls'] -%}
63
+ {%- if '<tool_call>' not in content_text.value -%}
64
+ {%- for tool_call in message['tool_calls'] -%}
65
+ {%- set fn = tool_call['function'] if tool_call['function'] is defined else tool_call -%}
66
+ {%- if loop.first and not (content_text.value | trim) -%}
67
+ {{- '<tool_call>\n{"name": "' + fn['name'] + '", "arguments": ' -}}
68
+ {%- else -%}
69
+ {{- '\n<tool_call>\n{"name": "' + fn['name'] + '", "arguments": ' -}}
70
+ {%- endif -%}
71
+ {%- if fn['arguments'] is string -%}{{- fn['arguments'] -}}
72
+ {%- else -%}{{- fn['arguments'] | tojson -}}
73
+ {%- endif -%}
74
+ {{- '}\n</tool_call>' -}}
75
+ {%- endfor -%}
76
+ {%- endif -%}
77
+ {%- endif -%}
78
+ {%- if not (is_tool and next_is_tool) -%}
79
+ {{- '<|im_end|>\n' -}}
80
+ {%- endif -%}
81
+ {%- endif -%}
82
+ {%- endfor -%}
83
+ {%- if add_generation_prompt -%}
84
+ {{- '<|im_start|>assistant\n' -}}
85
+ {%- if enable_thinking is defined -%}{{- ('<|think|>\n' if enable_thinking else '<|no_think|>\n') -}}{%- endif -%}
86
+ {%- endif -%}
config.json ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "allow_negative_eigenvalues": false,
3
+ "architecture": "TinyGDNForCausalLM",
4
+ "architectures": [
5
+ "TinyGDNForCausalLM"
6
+ ],
7
+ "attention_dropout": 0.0,
8
+ "attention_head_dim": 128,
9
+ "base_model": "kerzgrr/Tercet-R-1.0",
10
+ "bos_token_id": 0,
11
+ "chat_template": "smollm3",
12
+ "checkpoint_step": 5300,
13
+ "eos_token_id": 1,
14
+ "full_attention_interval": 4,
15
+ "hidden_size": 1024,
16
+ "initializer_range": 0.02,
17
+ "intermediate_size": 2624,
18
+ "layer_types": [
19
+ "gdn2",
20
+ "gdn2",
21
+ "gdn2",
22
+ "full_attention",
23
+ "gdn2",
24
+ "gdn2",
25
+ "gdn2",
26
+ "full_attention",
27
+ "gdn2",
28
+ "gdn2",
29
+ "gdn2",
30
+ "full_attention",
31
+ "gdn2",
32
+ "gdn2",
33
+ "gdn2",
34
+ "full_attention",
35
+ "gdn2",
36
+ "gdn2",
37
+ "gdn2",
38
+ "full_attention",
39
+ "gdn2",
40
+ "gdn2",
41
+ "gdn2",
42
+ "full_attention",
43
+ "gdn2",
44
+ "gdn2",
45
+ "gdn2",
46
+ "full_attention",
47
+ "gdn2",
48
+ "gdn2",
49
+ "gdn2",
50
+ "full_attention"
51
+ ],
52
+ "linear_conv_kernel_dim": 4,
53
+ "linear_expand_v": 1.0,
54
+ "linear_head_dim": 128,
55
+ "linear_num_heads": 8,
56
+ "linear_num_value_heads": 8,
57
+ "max_position_embeddings": 32768,
58
+ "model_family": "Tercet-R",
59
+ "model_name": "Tercet-R-1.1",
60
+ "model_type": "tiny_gdn",
61
+ "mtp_adapter_rank": 128,
62
+ "mtp_loss_weight": 0.0,
63
+ "mtp_num_heads": 0,
64
+ "num_attention_heads": 8,
65
+ "num_hidden_layers": 32,
66
+ "num_key_value_heads": 2,
67
+ "pad_token_id": 2,
68
+ "partial_rotary_factor": 0.5,
69
+ "reasoning": true,
70
+ "release": "1.1",
71
+ "rms_norm_eps": 1e-06,
72
+ "rope_theta": 1000000.0,
73
+ "sft_hours": 27.491864080583593,
74
+ "sft_val_loss_ema": 2.0271428378822587,
75
+ "sft_val_ppl_ema": 7.592362728419498,
76
+ "shared_layer_indices": [],
77
+ "stage": "sft",
78
+ "tie_word_embeddings": true,
79
+ "tool_calls": "smoltalk_json",
80
+ "torch_dtype": "bfloat16",
81
+ "training_sequence_length": 16384,
82
+ "transformers_version": "4.45.0",
83
+ "unk_token_id": 3,
84
+ "vocab_size": 49152,
85
+ "weights": "ema"
86
+ }
inference.py ADDED
@@ -0,0 +1,544 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Standalone chat inference for kerzgrr/Tercet-R-1.1.
3
+
4
+ Streams a live reasoning trace (`<think>` / `<|think|>`) and runs SmolTalk
5
+ tool-call rounds. Built-in `web-search`, `python`, and `calculator` tools
6
+ execute automatically. Other observations are sent as `<|tool_response|>`.
7
+
8
+ Examples:
9
+ python inference.py --prompt "What is the capital of France?"
10
+ python inference.py --tools web-search,python,calculator
11
+ python inference.py --no-think --prompt "Reply in one sentence."
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ import platform
20
+ import shutil
21
+ import subprocess
22
+ import sys
23
+ import time
24
+ import warnings
25
+ from dataclasses import fields
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ import torch
30
+ from safetensors.torch import load_file
31
+ from tokenizers import Tokenizer
32
+
33
+
34
+ def _silence_runtime_warnings() -> None:
35
+ patterns = (
36
+ r"tl\.make_block_ptr is deprecated",
37
+ r"Memory efficient kernel not used because",
38
+ r"Memory Efficient attention has been runtime disabled",
39
+ r"Flash attention kernel not used because",
40
+ r"Torch was not compiled with flash attention",
41
+ r"cuDNN attention kernel not used because",
42
+ r"cuDNN attention has been runtime disabled",
43
+ )
44
+ for pattern in patterns:
45
+ warnings.filterwarnings("ignore", message=pattern)
46
+
47
+
48
+ REPO_ID = "kerzgrr/Tercet-R-1.1"
49
+ FLA_COMMIT = "cbb0a72efb55c18ca0ef4f298298317573ad2cb3"
50
+ FLA_REPO = "https://github.com/fla-org/flash-linear-attention.git"
51
+ PATCH_FILES = (
52
+ "fla/__init__.py",
53
+ "fla/ops/__init__.py",
54
+ "fla/layers/__init__.py",
55
+ "fla/ops/simple_gla/__init__.py",
56
+ )
57
+ TINY_GDN_FILES = (
58
+ "tiny_gdn/__init__.py",
59
+ "tiny_gdn/config.py",
60
+ "tiny_gdn/model.py",
61
+ "tiny_gdn/tools.py",
62
+ "tiny_gdn/smoltalk_chat.py",
63
+ "tiny_gdn/chatml.py",
64
+ "tiny_gdn/cli_chat.py",
65
+ "tiny_gdn/detokenize.py",
66
+ "tiny_gdn/code_exec.py",
67
+ "tiny_gdn/web_search.py",
68
+ )
69
+
70
+
71
+ def _download(filename: str, local_dir: Path | None) -> Path:
72
+ from huggingface_hub import hf_hub_download
73
+
74
+ return Path(
75
+ hf_hub_download(
76
+ repo_id=REPO_ID,
77
+ filename=filename,
78
+ local_dir=str(local_dir) if local_dir else None,
79
+ )
80
+ )
81
+
82
+
83
+ def _run(cmd: list[str], *, cwd: Path | None = None, env: dict | None = None) -> None:
84
+ print("+", " ".join(cmd), flush=True)
85
+ merged = os.environ.copy()
86
+ if env:
87
+ merged.update(env)
88
+ merged.setdefault("PYTHONUTF8", "1")
89
+ merged.setdefault("PYTHONIOENCODING", "utf-8")
90
+ subprocess.check_call(cmd, cwd=str(cwd) if cwd else None, env=merged)
91
+
92
+
93
+ def _pip_install(*args: str) -> None:
94
+ _run([sys.executable, "-m", "pip", "install", *args])
95
+
96
+
97
+ def _fla_importable() -> tuple[bool, str]:
98
+ try:
99
+ from fla.layers.gdn2 import GatedDeltaNet2 # noqa: F401
100
+ except Exception as error: # noqa: BLE001
101
+ return False, str(error)
102
+ return True, ""
103
+
104
+
105
+ def _cache_root() -> Path:
106
+ override = os.environ.get("MONOSTICH_CACHE")
107
+ if override:
108
+ path = Path(override).expanduser().resolve()
109
+ else:
110
+ path = Path.home() / ".cache" / "tercet-r"
111
+ path.mkdir(parents=True, exist_ok=True)
112
+ return path
113
+
114
+
115
+ def _ensure_git() -> None:
116
+ if shutil.which("git") is None:
117
+ raise RuntimeError(
118
+ "git is required to auto-install flash-linear-attention. "
119
+ "Install Git and ensure it is on PATH."
120
+ )
121
+
122
+
123
+ def _apply_windows_fla_patches(fla_root: Path, local_dir: Path | None) -> None:
124
+ print("Applying Windows FLA import patches from the Hub …", flush=True)
125
+ for relative in PATCH_FILES:
126
+ source = _download(f"windows_fla_patches/{relative}", local_dir)
127
+ target = fla_root / relative
128
+ target.parent.mkdir(parents=True, exist_ok=True)
129
+ shutil.copy2(source, target)
130
+ print(f" patched {relative}", flush=True)
131
+
132
+
133
+ def _install_fla(local_dir: Path | None) -> None:
134
+ print("flash-linear-attention missing/broken — installing automatically …", flush=True)
135
+ _pip_install("einops", "numpy")
136
+ if platform.system() != "Windows":
137
+ _pip_install("--no-deps", f"git+{FLA_REPO}@{FLA_COMMIT}")
138
+ return
139
+
140
+ _ensure_git()
141
+ fla_root = _cache_root() / "flash-linear-attention"
142
+ if (fla_root / ".git").is_dir():
143
+ _run(["git", "fetch", "--depth", "1", "origin", FLA_COMMIT], cwd=fla_root)
144
+ _run(["git", "checkout", "--force", FLA_COMMIT], cwd=fla_root)
145
+ else:
146
+ if fla_root.exists():
147
+ shutil.rmtree(fla_root)
148
+ _run(["git", "clone", "--filter=blob:none", FLA_REPO, str(fla_root)])
149
+ _run(["git", "fetch", "--depth", "1", "origin", FLA_COMMIT], cwd=fla_root)
150
+ _run(["git", "checkout", "--force", FLA_COMMIT], cwd=fla_root)
151
+
152
+ _apply_windows_fla_patches(fla_root, local_dir)
153
+ _pip_install("--no-build-isolation", "--no-deps", "-e", str(fla_root))
154
+
155
+
156
+ def _ensure_fla(local_dir: Path | None) -> None:
157
+ ok, error = _fla_importable()
158
+ if ok:
159
+ return
160
+ print(f"FLA not ready ({error})", flush=True)
161
+ try:
162
+ _install_fla(local_dir)
163
+ except Exception as install_error: # noqa: BLE001
164
+ raise RuntimeError(
165
+ "Automatic flash-linear-attention install failed.\n"
166
+ f"Original import error: {error}\n"
167
+ f"Install error: {install_error}"
168
+ ) from install_error
169
+
170
+ for name in list(sys.modules):
171
+ if name == "fla" or name.startswith("fla."):
172
+ del sys.modules[name]
173
+
174
+ ok, error = _fla_importable()
175
+ if not ok:
176
+ raise RuntimeError(
177
+ "flash-linear-attention installed but still failed to import "
178
+ f"GatedDeltaNet2: {error}"
179
+ )
180
+ print("flash-linear-attention ready.", flush=True)
181
+
182
+
183
+ def _ensure_tiny_gdn(local_dir: Path | None) -> Path:
184
+ here = Path(__file__).resolve().parent
185
+ if (here / "tiny_gdn" / "cli_chat.py").is_file():
186
+ return here
187
+ if local_dir and (local_dir / "tiny_gdn" / "cli_chat.py").is_file():
188
+ return local_dir
189
+ for name in TINY_GDN_FILES:
190
+ _download(name, local_dir)
191
+ return _download("tiny_gdn/__init__.py", local_dir).parent.parent
192
+
193
+
194
+ def _sample(
195
+ logits: torch.Tensor,
196
+ *,
197
+ temperature: float,
198
+ top_p: float,
199
+ top_k: int,
200
+ generator: torch.Generator,
201
+ ) -> int:
202
+ logits = logits.float()
203
+ if temperature <= 1e-5:
204
+ return int(torch.argmax(logits).item())
205
+ logits = logits / temperature
206
+ if 0 < top_k < logits.shape[-1]:
207
+ threshold = torch.topk(logits, top_k).values[-1]
208
+ logits = logits.masked_fill(logits < threshold, -torch.inf)
209
+ if top_p < 1.0:
210
+ sorted_logits, sorted_indices = torch.sort(logits, descending=True)
211
+ probs = torch.softmax(sorted_logits, dim=-1)
212
+ remove = torch.cumsum(probs, dim=-1) > top_p
213
+ remove[1:] = remove[:-1].clone()
214
+ remove[0] = False
215
+ sorted_logits = sorted_logits.masked_fill(remove, -torch.inf)
216
+ logits = torch.full_like(logits, -torch.inf)
217
+ logits.scatter_(0, sorted_indices, sorted_logits)
218
+ probs = torch.softmax(logits, dim=-1).cpu()
219
+ return int(torch.multinomial(probs, 1, generator=generator).item())
220
+
221
+
222
+ def _apply_repetition_penalty(
223
+ logits: torch.Tensor,
224
+ token_ids: list[int],
225
+ penalty: float,
226
+ window: int,
227
+ ) -> torch.Tensor:
228
+ if penalty == 1.0 or not token_ids:
229
+ return logits
230
+ recent = token_ids[-window:] if window > 0 else token_ids
231
+ unique = torch.tensor(list(set(recent)), dtype=torch.long, device=logits.device)
232
+ score = logits[unique]
233
+ logits[unique] = torch.where(score > 0, score / penalty, score * penalty)
234
+ return logits
235
+
236
+
237
+ @torch.inference_mode()
238
+ def generate_turn(
239
+ model,
240
+ tokenizer: Tokenizer,
241
+ encode_chatml_messages,
242
+ IncrementalUtf8Decoder,
243
+ parse_tool_calls,
244
+ streamer,
245
+ messages: list[dict[str, Any]],
246
+ *,
247
+ tools: Any | None,
248
+ enable_thinking: bool,
249
+ max_new_tokens: int,
250
+ context_length: int,
251
+ temperature: float,
252
+ top_p: float,
253
+ top_k: int,
254
+ repetition_penalty: float,
255
+ repetition_window: int,
256
+ seed: int,
257
+ device: torch.device,
258
+ ) -> tuple[str, tuple]:
259
+ prompt_ids = encode_chatml_messages(
260
+ tokenizer,
261
+ messages,
262
+ tools=tools,
263
+ enable_thinking=enable_thinking,
264
+ )
265
+ eos_id = int(model.config.eos_token_id)
266
+ im_end = tokenizer.token_to_id("<|im_end|>")
267
+ stop = {eos_id}
268
+ if im_end is not None:
269
+ stop.add(im_end)
270
+
271
+ token_ids = list(prompt_ids[-context_length:])
272
+ generated: list[int] = []
273
+ decoder = IncrementalUtf8Decoder(tokenizer)
274
+ stop_reason = "max_new_tokens"
275
+ generator = torch.Generator(device="cpu")
276
+ generator.manual_seed(seed)
277
+ started = time.perf_counter()
278
+
279
+ input_ids = torch.tensor([token_ids], dtype=torch.long, device=device)
280
+ output = model(
281
+ input_ids,
282
+ return_logits=True,
283
+ logits_to_keep=1,
284
+ use_cache=True,
285
+ )
286
+
287
+ for step in range(max_new_tokens):
288
+ if output.logits is None:
289
+ raise RuntimeError("Model returned no logits")
290
+ next_logits = _apply_repetition_penalty(
291
+ output.logits[0, -1],
292
+ token_ids,
293
+ repetition_penalty,
294
+ repetition_window,
295
+ )
296
+ next_id = _sample(
297
+ next_logits,
298
+ temperature=temperature,
299
+ top_p=top_p,
300
+ top_k=top_k,
301
+ generator=generator,
302
+ )
303
+ if next_id in stop:
304
+ stop_reason = "stop"
305
+ break
306
+ token_ids.append(next_id)
307
+ generated.append(next_id)
308
+ decoder.push(next_id)
309
+ if streamer is not None:
310
+ streamer.update(decoder.text)
311
+ if step + 1 < max_new_tokens:
312
+ if output.past_key_values is None:
313
+ raise RuntimeError("Model returned no decode cache")
314
+ input_ids = torch.tensor([[next_id]], dtype=torch.long, device=device)
315
+ output = model(
316
+ input_ids,
317
+ return_logits=True,
318
+ logits_to_keep=1,
319
+ past_key_values=output.past_key_values,
320
+ use_cache=True,
321
+ )
322
+
323
+ decoded = decoder.finalize()
324
+ if streamer is not None:
325
+ streamer.finish()
326
+ elapsed = time.perf_counter() - started
327
+ tps = len(generated) / max(elapsed, 1e-9)
328
+ print(
329
+ f"[done] tokens={len(generated)} stop={stop_reason} {tps:.1f} tok/s",
330
+ file=sys.stderr,
331
+ flush=True,
332
+ )
333
+ return decoded, tuple(parse_tool_calls(decoded))
334
+
335
+
336
+ def parse_args() -> argparse.Namespace:
337
+ parser = argparse.ArgumentParser(description="Tercet-R-1.1 chat inference")
338
+ parser.add_argument("--prompt", default=None, help="Single user prompt")
339
+ parser.add_argument("--system", default="", help="Optional system prompt")
340
+ parser.add_argument(
341
+ "--think",
342
+ dest="enable_thinking",
343
+ action="store_true",
344
+ default=True,
345
+ )
346
+ parser.add_argument(
347
+ "--no-think",
348
+ dest="enable_thinking",
349
+ action="store_false",
350
+ )
351
+ parser.add_argument(
352
+ "--tools",
353
+ default="",
354
+ help="Built-in tools: web-search, python, calculator (comma-separated)",
355
+ )
356
+ parser.add_argument("--tools-json", default=None)
357
+ parser.add_argument("--max-tool-rounds", type=int, default=8)
358
+ parser.add_argument("--max-new-tokens", type=int, default=4096)
359
+ parser.add_argument("--temperature", type=float, default=0.7)
360
+ parser.add_argument("--top-p", type=float, default=0.9)
361
+ parser.add_argument("--top-k", type=int, default=50)
362
+ parser.add_argument("--repetition-penalty", type=float, default=1.08)
363
+ parser.add_argument("--repetition-window", type=int, default=256)
364
+ parser.add_argument("--context-length", type=int, default=16384)
365
+ parser.add_argument("--seed", type=int, default=42)
366
+ parser.add_argument(
367
+ "--device",
368
+ default="cuda" if torch.cuda.is_available() else "cpu",
369
+ choices=["cuda", "cpu"],
370
+ )
371
+ parser.add_argument("--no-stream", action="store_true")
372
+ parser.add_argument("--no-color", action="store_true")
373
+ parser.add_argument("--local-dir", default=None)
374
+ parser.add_argument("--repo-id", default=REPO_ID)
375
+ return parser.parse_args()
376
+
377
+
378
+ def main() -> int:
379
+ _silence_runtime_warnings()
380
+ args = parse_args()
381
+ global REPO_ID
382
+ REPO_ID = args.repo_id
383
+ local_dir = Path(args.local_dir).resolve() if args.local_dir else None
384
+
385
+ print(f"Loading Tercet-R-1.1 from huggingface.co/{REPO_ID} …", flush=True)
386
+ try:
387
+ package_root = _ensure_tiny_gdn(local_dir)
388
+ except Exception as error: # noqa: BLE001
389
+ print(f"Failed to resolve tiny_gdn package: {error}", file=sys.stderr)
390
+ return 1
391
+
392
+ if str(package_root) not in sys.path:
393
+ sys.path.insert(0, str(package_root))
394
+
395
+ try:
396
+ _ensure_fla(local_dir)
397
+ except Exception as error: # noqa: BLE001
398
+ print(str(error), file=sys.stderr)
399
+ return 1
400
+
401
+ try:
402
+ from tiny_gdn import TinyGDNConfig, TinyGDNForCausalLM
403
+ from tiny_gdn.chatml import encode_chatml_messages
404
+ from tiny_gdn.cli_chat import (
405
+ ChatLoopState,
406
+ LiveReasoningStreamer,
407
+ append_tool_round,
408
+ apply_slash_command,
409
+ prompt_tool_results,
410
+ resolve_cli_tools,
411
+ )
412
+ from tiny_gdn.detokenize import IncrementalUtf8Decoder
413
+ from tiny_gdn.tools import parse_tool_calls
414
+ except ImportError as error:
415
+ print(f"Could not import tiny_gdn chat stack: {error}", file=sys.stderr)
416
+ return 1
417
+
418
+ try:
419
+ raw_json = (
420
+ Path(args.tools_json).read_text(encoding="utf-8")
421
+ if args.tools_json
422
+ else None
423
+ )
424
+ tools = resolve_cli_tools(args.tools, raw_json)
425
+ except (OSError, ValueError, json.JSONDecodeError) as error:
426
+ print(error, file=sys.stderr)
427
+ return 1
428
+
429
+ weights_path = _download("model.safetensors", local_dir)
430
+ tok_path = _download("tokenizer.json", local_dir)
431
+ cfg_path = _download("config.json", local_dir)
432
+
433
+ raw = json.loads(cfg_path.read_text(encoding="utf-8"))
434
+ allowed = {item.name for item in fields(TinyGDNConfig)}
435
+ payload = {key: value for key, value in raw.items() if key in allowed}
436
+ if "shared_layer_indices" in payload:
437
+ payload["shared_layer_indices"] = tuple(payload["shared_layer_indices"])
438
+ config = TinyGDNConfig(**payload)
439
+
440
+ device = torch.device(args.device)
441
+ if device.type == "cuda" and not torch.cuda.is_available():
442
+ print("CUDA requested but unavailable; falling back to CPU.", flush=True)
443
+ device = torch.device("cpu")
444
+ dtype = torch.bfloat16 if device.type == "cuda" else torch.float32
445
+
446
+ print(
447
+ f"Building TinyGDN ({config.num_hidden_layers}L / {config.hidden_size}d) "
448
+ f"on {device} …",
449
+ flush=True,
450
+ )
451
+ model = TinyGDNForCausalLM(config)
452
+ state = load_file(str(weights_path), device="cpu")
453
+ model.load_state_dict(state, strict=True)
454
+ del state
455
+ model = model.to(device=device, dtype=dtype)
456
+ model.eval()
457
+ model.requires_grad_(False)
458
+
459
+ tokenizer = Tokenizer.from_file(str(tok_path))
460
+ context_length = min(args.context_length, config.max_position_embeddings)
461
+ color = not args.no_color and sys.stdout.isatty()
462
+
463
+ def read_line(prompt: str) -> str:
464
+ return input(prompt)
465
+
466
+ def run_turn(state: ChatLoopState) -> None:
467
+ for round_index in range(max(1, args.max_tool_rounds + 1)):
468
+ streamer = (
469
+ None
470
+ if args.no_stream
471
+ else LiveReasoningStreamer(sys.stdout, color=color)
472
+ )
473
+ if streamer is not None:
474
+ print("assistant> ", end="", flush=True)
475
+ text, calls = generate_turn(
476
+ model,
477
+ tokenizer,
478
+ encode_chatml_messages,
479
+ IncrementalUtf8Decoder,
480
+ parse_tool_calls,
481
+ streamer,
482
+ state.messages,
483
+ tools=state.tools,
484
+ enable_thinking=state.enable_thinking,
485
+ max_new_tokens=args.max_new_tokens,
486
+ context_length=context_length,
487
+ temperature=args.temperature,
488
+ top_p=args.top_p,
489
+ top_k=args.top_k,
490
+ repetition_penalty=args.repetition_penalty,
491
+ repetition_window=args.repetition_window,
492
+ seed=args.seed,
493
+ device=device,
494
+ )
495
+ if args.no_stream:
496
+ print(f"assistant> {text}")
497
+ if not calls or round_index >= args.max_tool_rounds:
498
+ state.messages.append({"role": "assistant", "content": text})
499
+ return
500
+ results = prompt_tool_results(calls, read_line=read_line)
501
+ append_tool_round(state.messages, text, results)
502
+
503
+ state = ChatLoopState(
504
+ system=args.system,
505
+ enable_thinking=args.enable_thinking,
506
+ tools=tools,
507
+ )
508
+ state.reset()
509
+
510
+ if args.prompt is not None:
511
+ state.add_user(args.prompt)
512
+ run_turn(state)
513
+ return 0
514
+
515
+ print(
516
+ "Interactive Tercet-R-1.1 chat. "
517
+ "Commands: /think /no_think /system … /reset /exit\n"
518
+ "web-search, python, and calculator run automatically when enabled. "
519
+ "Other <tool_call> observations can be pasted at the prompt.",
520
+ flush=True,
521
+ )
522
+ while True:
523
+ try:
524
+ user_input = input("user> ")
525
+ except (EOFError, KeyboardInterrupt):
526
+ print()
527
+ return 0
528
+ text = user_input.strip()
529
+ if not text:
530
+ continue
531
+ status = apply_slash_command(state, text)
532
+ if status == "exit":
533
+ return 0
534
+ if status is not None:
535
+ print(f"({status})", flush=True)
536
+ continue
537
+ state.add_user(text)
538
+ run_turn(state)
539
+ print(flush=True)
540
+ return 0
541
+
542
+
543
+ if __name__ == "__main__":
544
+ raise SystemExit(main())
merges.txt ADDED
The diff for this file is too large to render. See raw diff
 
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a3bd09e50ef1d40c70ad4fd1ab7bfca3a553f600bd08a0fffec0ea5e47bda886
3
+ size 1003334200
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ torch>=2.4.0
2
+ safetensors>=0.4.0
3
+ tokenizers>=0.20.0
4
+ huggingface_hub>=0.26.0
5
+ einops>=0.8.0
6
+ numpy>=1.26.0
7
+ sympy>=1.13.0
8
+
9
+ # flash-linear-attention is auto-installed by inference.py on first run.
sft_config.json ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "adam_beta1": 0.9,
3
+ "adam_beta2": 0.95,
4
+ "adam_epsilon": 1e-08,
5
+ "cpu_prefetch_factor": 4,
6
+ "cpu_workers": 2,
7
+ "dataset_manifest": "data/tokenized/stage3-sft/manifest.json",
8
+ "ema_inv_gamma": 1.0,
9
+ "ema_max_decay": 0.9999,
10
+ "ema_power": 0.75,
11
+ "enable_gradient_checkpointing": true,
12
+ "full_shuffle": true,
13
+ "gradient_accumulation_steps": 16,
14
+ "gradient_clip_norm": 1.0,
15
+ "initial_run_dir": "runs/tercet-r-instruct",
16
+ "initial_weights": "ema",
17
+ "learning_rate": 3e-05,
18
+ "log_every_optimizer_steps": 1,
19
+ "logit_z_loss_coefficient": 0.0001,
20
+ "maximum_checkpoints": 5,
21
+ "maximum_optimizer_steps": null,
22
+ "micro_batch_size": 1,
23
+ "minimum_learning_rate_ratio": 0.1,
24
+ "muon_learning_rate": 0.01,
25
+ "muon_momentum": 0.95,
26
+ "optimizer": "adamw",
27
+ "output_dir": "runs/tercet-r-stage3",
28
+ "require_complete_pretraining": false,
29
+ "require_complete_sft_source": false,
30
+ "save_every_optimizer_steps": 100,
31
+ "seed": 20260902,
32
+ "sequence_length": 16384,
33
+ "shuffle_block_sequences": 4096,
34
+ "validation_batches": 16,
35
+ "warmup_ratio": 0.01,
36
+ "warmup_steps": 200,
37
+ "weight_decay": 0.0
38
+ }
special_token_ids.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "bos_token": "<|begin_of_text|>",
3
+ "eos_token": "<|end_of_text|>",
4
+ "pad_token": "<|padding|>",
5
+ "unk_token": "<|unknown|>",
6
+ "im_start": "<|im_start|>",
7
+ "im_end": "<|im_end|>",
8
+ "think_control": "<|think|>",
9
+ "no_think_control": "<|no_think|>",
10
+ "tool_response": "<|tool_response|>",
11
+ "bos_token_id": 0,
12
+ "eos_token_id": 1,
13
+ "pad_token_id": 2,
14
+ "unk_token_id": 3
15
+ }
special_tokens_map.json ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "bos_token": "<|begin_of_text|>",
3
+ "eos_token": "<|end_of_text|>",
4
+ "pad_token": "<|padding|>",
5
+ "unk_token": "<|unknown|>",
6
+ "additional_special_tokens": [
7
+ "<|im_start|>",
8
+ "<|im_end|>",
9
+ "<|tool_call|>",
10
+ "<|tool_response|>",
11
+ "<think>",
12
+ "</think>",
13
+ "<|fim_prefix|>",
14
+ "<|fim_middle|>",
15
+ "<|fim_suffix|>",
16
+ "<|fim_pad|>",
17
+ "<|no_think|>",
18
+ "<|think|>",
19
+ "<|reserved_002|>",
20
+ "<|reserved_003|>",
21
+ "<|reserved_004|>",
22
+ "<|reserved_005|>",
23
+ "<|reserved_006|>",
24
+ "<|reserved_007|>",
25
+ "<|reserved_008|>",
26
+ "<|reserved_009|>",
27
+ "<|reserved_010|>",
28
+ "<|reserved_011|>",
29
+ "<|reserved_012|>",
30
+ "<|reserved_013|>",
31
+ "<|reserved_014|>",
32
+ "<|reserved_015|>",
33
+ "<|reserved_016|>",
34
+ "<|reserved_017|>",
35
+ "<|reserved_018|>",
36
+ "<|reserved_019|>",
37
+ "<|reserved_020|>",
38
+ "<|reserved_021|>",
39
+ "<|reserved_022|>",
40
+ "<|reserved_023|>",
41
+ "<|reserved_024|>",
42
+ "<|reserved_025|>",
43
+ "<|reserved_026|>",
44
+ "<|reserved_027|>",
45
+ "<|reserved_028|>",
46
+ "<|reserved_029|>",
47
+ "<|reserved_030|>",
48
+ "<|reserved_031|>",
49
+ "<|reserved_032|>",
50
+ "<|reserved_033|>",
51
+ "<|reserved_034|>",
52
+ "<|reserved_035|>",
53
+ "<|reserved_036|>",
54
+ "<|reserved_037|>",
55
+ "<|reserved_038|>",
56
+ "<|reserved_039|>",
57
+ "<|reserved_040|>",
58
+ "<|reserved_041|>",
59
+ "<|reserved_042|>",
60
+ "<|reserved_043|>",
61
+ "<|reserved_044|>",
62
+ "<|reserved_045|>",
63
+ "<|reserved_046|>",
64
+ "<|reserved_047|>",
65
+ "<|reserved_048|>",
66
+ "<|reserved_049|>"
67
+ ]
68
+ }
tiny_gdn/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from tiny_gdn.config import TinyGDNConfig
2
+ from tiny_gdn.model import TinyGDNForCausalLM, TinyGDNOutput
3
+
4
+ __all__ = [
5
+ "TinyGDNConfig",
6
+ "TinyGDNForCausalLM",
7
+ "TinyGDNOutput",
8
+ ]
tiny_gdn/chatml.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from tiny_gdn.smoltalk_chat import (
6
+ NO_THINK_PREFIX,
7
+ THINK_CONTROL_PREFIX,
8
+ kwargs_from_inference_request,
9
+ materialize_smoltalk_messages,
10
+ merge_kwargs_with_system_message,
11
+ resolve_reasoning_mode,
12
+ )
13
+ from tiny_gdn.tools import prepare_inference_messages
14
+
15
+ THINK_START = "<think>"
16
+ THINK_END = "</think>"
17
+
18
+
19
+ def decode_chat_completion(tokenizer: Any, token_ids: list[int]) -> str:
20
+ return tokenizer.decode(token_ids, skip_special_tokens=False)
21
+
22
+
23
+ def append_assistant_generation_prompt(
24
+ tokenizer: Any,
25
+ token_ids: list[int],
26
+ *,
27
+ enable_thinking: bool | None = None,
28
+ ) -> None:
29
+ im_start = tokenizer.token_to_id("<|im_start|>")
30
+ if im_start is None:
31
+ raise RuntimeError("Tokenizer is missing <|im_start|>")
32
+ token_ids.append(im_start)
33
+ token_ids.extend(
34
+ tokenizer.encode("assistant\n", add_special_tokens=False).ids
35
+ )
36
+ if enable_thinking is True:
37
+ token_ids.extend(
38
+ tokenizer.encode(THINK_CONTROL_PREFIX, add_special_tokens=False).ids
39
+ )
40
+ elif enable_thinking is False:
41
+ token_ids.extend(
42
+ tokenizer.encode(NO_THINK_PREFIX, add_special_tokens=False).ids
43
+ )
44
+
45
+
46
+ def encode_chatml_messages(
47
+ tokenizer: Any,
48
+ messages: list[dict[str, Any]],
49
+ *,
50
+ tools: Any | None = None,
51
+ enable_thinking: bool | None = None,
52
+ xml_tools: Any | None = None,
53
+ python_tools: Any | None = None,
54
+ ) -> list[int]:
55
+ if enable_thinking is None:
56
+ prepared = prepare_inference_messages(messages, tools=tools)
57
+ assistant_thinking_control = None
58
+ else:
59
+ kwargs = kwargs_from_inference_request(
60
+ enable_thinking=enable_thinking,
61
+ xml_tools=xml_tools,
62
+ python_tools=python_tools,
63
+ tools=tools,
64
+ )
65
+ prepared = materialize_smoltalk_messages(messages, kwargs)
66
+ _, resolved = merge_kwargs_with_system_message(messages, kwargs)
67
+ assistant_thinking_control = (
68
+ resolve_reasoning_mode(
69
+ resolved.enable_thinking,
70
+ resolved.custom_instructions,
71
+ )
72
+ == "/think"
73
+ )
74
+ required_tokens = {
75
+ token: tokenizer.token_to_id(token)
76
+ for token in (
77
+ "<|begin_of_text|>",
78
+ "<|im_start|>",
79
+ "<|im_end|>",
80
+ )
81
+ }
82
+ if any(token_id is None for token_id in required_tokens.values()):
83
+ raise RuntimeError("Tokenizer is missing ChatML special tokens")
84
+ bos_id = required_tokens["<|begin_of_text|>"]
85
+ im_start_id = required_tokens["<|im_start|>"]
86
+ im_end_id = required_tokens["<|im_end|>"]
87
+ if bos_id is None or im_start_id is None or im_end_id is None:
88
+ raise RuntimeError("Tokenizer ChatML IDs could not be resolved")
89
+
90
+ token_ids = [bos_id]
91
+ newline_ids = tokenizer.encode("\n", add_special_tokens=False).ids
92
+ allowed_roles = {"system", "user", "assistant"}
93
+ for message_index, message in enumerate(prepared):
94
+ role = message["role"]
95
+ content = message["content"]
96
+ masked_prefix = message.get("masked_prefix", "")
97
+ if role not in allowed_roles:
98
+ raise ValueError(
99
+ f"Unsupported chat role at index {message_index}: {role!r}"
100
+ )
101
+ if not isinstance(masked_prefix, str):
102
+ raise ValueError(
103
+ f"Masked prefix at index {message_index} must be a string"
104
+ )
105
+ if not content.strip():
106
+ raise ValueError(
107
+ f"Chat content at index {message_index} must be non-empty"
108
+ )
109
+ token_ids.append(im_start_id)
110
+ token_ids.extend(
111
+ tokenizer.encode(f"{role}\n", add_special_tokens=False).ids
112
+ )
113
+ if masked_prefix:
114
+ token_ids.extend(
115
+ tokenizer.encode(masked_prefix, add_special_tokens=False).ids
116
+ )
117
+ token_ids.extend(
118
+ tokenizer.encode(content, add_special_tokens=False).ids
119
+ )
120
+ token_ids.append(im_end_id)
121
+ token_ids.extend(newline_ids)
122
+ if prepared[-1]["role"] != "user":
123
+ raise ValueError("The final chat message must have role 'user'")
124
+ append_assistant_generation_prompt(
125
+ tokenizer,
126
+ token_ids,
127
+ enable_thinking=assistant_thinking_control,
128
+ )
129
+ return token_ids
tiny_gdn/cli_chat.py ADDED
@@ -0,0 +1,484 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Terminal chat: live reasoning stream plus SmolTalk tool-call rounds.
2
+
3
+ Tercet-R assistant turns may contain:
4
+
5
+ - a zero-loss `<|think|>` / `<|no_think|>` control prefix
6
+ - a `<think>…</think>` reasoning block
7
+ - one or more SmolTalk JSON `<tool_call>` blocks (NVIDIA XML is also parsed)
8
+
9
+ This module colours those regions as tokens arrive and, after a completed
10
+ turn, collects tool observations (prefixed with `<|tool_response|>`) so the
11
+ model can continue the same conversation.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import sys
18
+ from collections.abc import Callable, Sequence
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Literal, TextIO
21
+
22
+ from tiny_gdn.smoltalk_chat import (
23
+ TOOL_RESPONSE_TOKEN,
24
+ format_smoltalk_tool_call,
25
+ wrap_smoltalk_tool_result,
26
+ )
27
+ from tiny_gdn.code_exec import (
28
+ CALCULATOR_TOOL,
29
+ NEMOTRON_PYTHON_EXEC_TOOL,
30
+ execute_math_tool,
31
+ is_auto_math_tool_name,
32
+ reset_default_python_session,
33
+ )
34
+ from tiny_gdn.tools import ParsedToolCall, parse_tool_calls
35
+ from tiny_gdn.web_search import (
36
+ NEMOTRON_WEB_SEARCH_TOOL,
37
+ is_web_search_tool_name,
38
+ query_from_arguments,
39
+ search_web,
40
+ )
41
+
42
+
43
+ SegmentKind = Literal["answer", "think", "tool_call"]
44
+ MarkerKind = Literal[
45
+ "think_open",
46
+ "think_close",
47
+ "think_control",
48
+ "no_think",
49
+ "tool_open",
50
+ "tool_close",
51
+ ]
52
+
53
+ THINK_OPEN = "<think>"
54
+ THINK_CLOSE = "</think>"
55
+ TOOL_CALL_OPEN = "<tool_call>"
56
+ TOOL_CALL_CLOSE = "</tool_call>"
57
+ THINK_CONTROL = "<|think|>"
58
+ NO_THINK_CONTROL = "<|no_think|>"
59
+
60
+ MARKERS: tuple[tuple[str, MarkerKind], ...] = (
61
+ (THINK_CLOSE, "think_close"),
62
+ (TOOL_CALL_CLOSE, "tool_close"),
63
+ (THINK_OPEN, "think_open"),
64
+ (TOOL_CALL_OPEN, "tool_open"),
65
+ (NO_THINK_CONTROL, "no_think"),
66
+ (THINK_CONTROL, "think_control"),
67
+ )
68
+
69
+ ANSI = {
70
+ "think": "\033[2;33m",
71
+ "tool_call": "\033[36m",
72
+ "answer": "\033[0m",
73
+ "reset": "\033[0m",
74
+ }
75
+
76
+ DEFAULT_MAX_TOOL_ROUNDS = 8
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class ContentSegment:
81
+ kind: SegmentKind
82
+ text: str
83
+ open: bool = False
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class GeneratedTurn:
88
+ text: str
89
+ token_count: int
90
+ stop_reason: str
91
+ tool_calls: tuple[ParsedToolCall, ...]
92
+
93
+
94
+ def first_marker(text: str) -> tuple[int, str, MarkerKind] | None:
95
+ best: tuple[int, str, MarkerKind] | None = None
96
+ for marker, kind in MARKERS:
97
+ at = text.find(marker)
98
+ if at < 0:
99
+ continue
100
+ if (
101
+ best is None
102
+ or at < best[0]
103
+ or (at == best[0] and len(marker) > len(best[1]))
104
+ ):
105
+ best = (at, marker, kind)
106
+ return best
107
+
108
+
109
+ def holdback_prefix_length(text: str) -> int:
110
+ if not text:
111
+ return 0
112
+ keep = 0
113
+ for marker, _kind in MARKERS:
114
+ limit = min(len(marker) - 1, len(text))
115
+ for size in range(1, limit + 1):
116
+ if marker.startswith(text[-size:]):
117
+ keep = max(keep, size)
118
+ return keep
119
+
120
+
121
+ def mode_after_marker(kind: MarkerKind, current: SegmentKind) -> SegmentKind:
122
+ if kind in {"think_open", "think_control"}:
123
+ return "think"
124
+ if kind in {"think_close", "no_think", "tool_close"}:
125
+ return "answer"
126
+ if kind == "tool_open":
127
+ return "tool_call"
128
+ return current
129
+
130
+
131
+ def _segment_text(segment: ContentSegment) -> str:
132
+ return segment.text.strip()
133
+
134
+
135
+ def format_assistant_markdown(source: str) -> str:
136
+ """Render a streamed assistant turn as a single markdown block."""
137
+
138
+ think_parts: list[str] = []
139
+ answer_parts: list[str] = []
140
+ tool_parts: list[str] = []
141
+ for segment in split_assistant_segments(source):
142
+ text = _segment_text(segment)
143
+ if not text:
144
+ continue
145
+ if segment.kind == "think":
146
+ think_parts.append(text)
147
+ elif segment.kind == "tool_call":
148
+ tool_parts.append(text)
149
+ else:
150
+ answer_parts.append(text)
151
+ blocks: list[str] = []
152
+ if think_parts:
153
+ blocks.append("**Reasoning**\n\n" + "\n\n".join(think_parts))
154
+ if answer_parts:
155
+ blocks.append("\n\n".join(answer_parts))
156
+ for tool in tool_parts:
157
+ blocks.append(f"```tool_call\n{tool}\n```")
158
+ return "\n\n".join(blocks).strip()
159
+
160
+
161
+ def format_assistant_chat_messages(source: str) -> list[dict[str, Any]]:
162
+ """Split a turn into Gradio thoughts plus a normal assistant chat message.
163
+
164
+ Messages with ``metadata.title`` render as collapsible thoughts. The reply
165
+ has no metadata so Gradio shows it as the chat bubble.
166
+ """
167
+
168
+ think_parts: list[str] = []
169
+ answer_parts: list[str] = []
170
+ tool_parts: list[str] = []
171
+ think_pending = False
172
+ for segment in split_assistant_segments(source):
173
+ text = _segment_text(segment)
174
+ if segment.kind == "think":
175
+ think_pending = segment.open
176
+ if text:
177
+ think_parts.append(text)
178
+ continue
179
+ think_pending = False
180
+ if not text:
181
+ continue
182
+ if segment.kind == "tool_call":
183
+ tool_parts.append(text)
184
+ else:
185
+ answer_parts.append(text)
186
+
187
+ messages: list[dict[str, Any]] = []
188
+ if think_parts:
189
+ messages.append(
190
+ {
191
+ "role": "assistant",
192
+ "content": "\n\n".join(think_parts),
193
+ "metadata": {
194
+ "title": "Reasoning",
195
+ "status": "pending" if think_pending else "done",
196
+ },
197
+ }
198
+ )
199
+ for tool in tool_parts:
200
+ messages.append(
201
+ {
202
+ "role": "assistant",
203
+ "content": f"```json\n{tool}\n```",
204
+ "metadata": {"title": "Tool call", "status": "done"},
205
+ }
206
+ )
207
+ answer = "\n\n".join(answer_parts)
208
+ if answer or not messages:
209
+ messages.append({"role": "assistant", "content": answer})
210
+ return messages
211
+
212
+
213
+ def split_assistant_segments(source: str) -> list[ContentSegment]:
214
+ """Split a completed (or in-progress) assistant turn for tests / replay."""
215
+
216
+ segments: list[ContentSegment] = []
217
+ mode: SegmentKind = "answer"
218
+ cursor = 0
219
+ while cursor < len(source):
220
+ found = first_marker(source[cursor:])
221
+ if found is None:
222
+ tail = source[cursor:]
223
+ if tail:
224
+ segments.append(ContentSegment(kind=mode, text=tail, open=True))
225
+ break
226
+ at, marker, kind = found
227
+ at += cursor
228
+ if at > cursor:
229
+ segments.append(
230
+ ContentSegment(kind=mode, text=source[cursor:at], open=False)
231
+ )
232
+ mode = mode_after_marker(kind, mode)
233
+ cursor = at + len(marker)
234
+ return [segment for segment in segments if segment.text]
235
+
236
+
237
+ class LiveReasoningStreamer:
238
+ """Colour reasoning and tool-call regions as decoded text grows."""
239
+
240
+ def __init__(
241
+ self,
242
+ writer: TextIO | None = None,
243
+ *,
244
+ color: bool | None = None,
245
+ ) -> None:
246
+ self.writer = writer if writer is not None else sys.stdout
247
+ if color is None:
248
+ color = bool(getattr(self.writer, "isatty", lambda: False)())
249
+ self.color = color
250
+ self._seen = ""
251
+ self._hold = ""
252
+ self._mode: SegmentKind = "answer"
253
+ self._style: SegmentKind | None = None
254
+ self._emitted_think_label = False
255
+ self._emitted_tool_label = False
256
+
257
+ def update(self, decoded: str) -> None:
258
+ if decoded.startswith(self._seen):
259
+ delta = decoded[len(self._seen) :]
260
+ else:
261
+ delta = decoded
262
+ self._seen = decoded
263
+ if delta:
264
+ self._consume(delta, final=False)
265
+
266
+ def finish(self) -> str:
267
+ if self._hold:
268
+ self._emit(self._hold)
269
+ self._hold = ""
270
+ self._set_style(None)
271
+ self.writer.write("\n")
272
+ self.writer.flush()
273
+ return self._seen
274
+
275
+ def _consume(self, delta: str, *, final: bool) -> None:
276
+ buffer = self._hold + delta
277
+ self._hold = ""
278
+ while buffer:
279
+ found = first_marker(buffer)
280
+ if found is None:
281
+ keep = 0 if final else holdback_prefix_length(buffer)
282
+ if keep:
283
+ self._emit(buffer[:-keep])
284
+ self._hold = buffer[-keep:]
285
+ else:
286
+ self._emit(buffer)
287
+ return
288
+ at, marker, kind = found
289
+ if at:
290
+ self._emit(buffer[:at])
291
+ self._switch(kind)
292
+ buffer = buffer[at + len(marker) :]
293
+ if final:
294
+ return
295
+
296
+ def _switch(self, kind: MarkerKind) -> None:
297
+ nxt = mode_after_marker(kind, self._mode)
298
+ if nxt != self._mode and nxt == "answer":
299
+ self._emit_plain("\n")
300
+ self._mode = nxt
301
+ if nxt == "think" and not self._emitted_think_label:
302
+ self._emit_plain("\n")
303
+ self._set_style("think")
304
+ self._emit_plain("reasoning ")
305
+ self._emitted_think_label = True
306
+ elif nxt == "tool_call" and not self._emitted_tool_label:
307
+ self._emit_plain("\n")
308
+ self._set_style("tool_call")
309
+ self._emit_plain("tool_call ")
310
+ self._emitted_tool_label = True
311
+ elif nxt == "answer":
312
+ self._set_style("answer")
313
+
314
+ def _emit(self, text: str) -> None:
315
+ if not text:
316
+ return
317
+ self._set_style(self._mode)
318
+ self.writer.write(text)
319
+ self.writer.flush()
320
+
321
+ def _emit_plain(self, text: str) -> None:
322
+ if not text:
323
+ return
324
+ self._set_style(None)
325
+ self.writer.write(text)
326
+ self.writer.flush()
327
+
328
+ def _set_style(self, kind: SegmentKind | None) -> None:
329
+ if not self.color:
330
+ self._style = kind
331
+ return
332
+ if kind == self._style:
333
+ return
334
+ self.writer.write(ANSI["reset"])
335
+ if kind in {"think", "tool_call"}:
336
+ self.writer.write(ANSI[kind])
337
+ self._style = kind
338
+
339
+
340
+ def prompt_tool_results(
341
+ calls: Sequence[ParsedToolCall],
342
+ *,
343
+ read_line: Callable[[str], str],
344
+ writer: TextIO | None = None,
345
+ execute_web_search: bool = True,
346
+ ) -> list[str]:
347
+ out = writer if writer is not None else sys.stdout
348
+ results: list[str] = []
349
+ auto_search = sum(
350
+ 1
351
+ for call in calls
352
+ if execute_web_search and is_web_search_tool_name(call.name)
353
+ )
354
+ auto_math = sum(1 for call in calls if is_auto_math_tool_name(call.name))
355
+ if auto_search:
356
+ out.write(
357
+ f"\n{auto_search} web-search call(s) will run automatically "
358
+ f"(Tavily JSON, sent as {TOOL_RESPONSE_TOKEN}).\n"
359
+ )
360
+ out.flush()
361
+ if auto_math:
362
+ out.write(
363
+ f"\n{auto_math} python/calculator call(s) will run automatically "
364
+ f"(sent as {TOOL_RESPONSE_TOKEN}).\n"
365
+ )
366
+ out.flush()
367
+ manual = len(calls) - auto_search - auto_math
368
+ if manual:
369
+ out.write(
370
+ f"\n{manual} tool call(s). Paste each observation; "
371
+ f"it is sent as {TOOL_RESPONSE_TOKEN}.\n"
372
+ )
373
+ out.flush()
374
+ for index, call in enumerate(calls, start=1):
375
+ out.write(
376
+ f"\n[{index}/{len(calls)}] "
377
+ f"{format_smoltalk_tool_call(call.name, call.arguments)}\n"
378
+ )
379
+ out.flush()
380
+ if execute_web_search and is_web_search_tool_name(call.name):
381
+ query = query_from_arguments(call.arguments)
382
+ out.write(f"searching {query!r}…\n")
383
+ out.flush()
384
+ results.append(search_web(query))
385
+ continue
386
+ if is_auto_math_tool_name(call.name):
387
+ out.write("running python…\n")
388
+ out.flush()
389
+ try:
390
+ results.append(execute_math_tool(call.name, call.arguments))
391
+ except Exception as error:
392
+ results.append(f"Error: {error}")
393
+ continue
394
+ results.append(read_line(f"result[{call.name}]> "))
395
+ return results
396
+
397
+
398
+ def append_tool_round(
399
+ messages: list[dict[str, Any]],
400
+ assistant_text: str,
401
+ raw_results: Sequence[str],
402
+ ) -> None:
403
+ messages.append({"role": "assistant", "content": assistant_text})
404
+ for raw in raw_results:
405
+ wrapped = wrap_smoltalk_tool_result(raw)
406
+ if not wrapped:
407
+ raise ValueError("Tool result cannot be empty")
408
+ messages.append({"role": "tool", "content": raw})
409
+
410
+
411
+ def resolve_cli_tools(spec: str | None, tools_json: str | None) -> list[dict[str, Any]] | None:
412
+ tools: list[dict[str, Any]] = []
413
+ if spec:
414
+ for name in spec.split(","):
415
+ key = name.strip().lower()
416
+ if not key or key in {"none", "off"}:
417
+ continue
418
+ if is_web_search_tool_name(key):
419
+ tools.append(NEMOTRON_WEB_SEARCH_TOOL)
420
+ continue
421
+ if key in {"python", "python-exec", "code-interpreter"}:
422
+ tools.append(NEMOTRON_PYTHON_EXEC_TOOL)
423
+ continue
424
+ if key in {"calculator", "calc"}:
425
+ tools.append(CALCULATOR_TOOL)
426
+ continue
427
+ raise ValueError(
428
+ f"Unknown built-in tool {name!r}. "
429
+ "Use web-search, python, calculator, or --tools-json."
430
+ )
431
+ if tools_json:
432
+ payload = json.loads(tools_json)
433
+ if isinstance(payload, dict):
434
+ tools.append(payload)
435
+ elif isinstance(payload, list):
436
+ tools.extend(payload)
437
+ else:
438
+ raise ValueError("tools JSON must be an object or array")
439
+ return tools or None
440
+
441
+
442
+ @dataclass
443
+ class ChatLoopState:
444
+ messages: list[dict[str, Any]] = field(default_factory=list)
445
+ system: str = ""
446
+ enable_thinking: bool = True
447
+ tools: list[dict[str, Any]] | None = None
448
+
449
+ def reset(self) -> None:
450
+ self.messages = []
451
+ reset_default_python_session()
452
+ if self.system.strip():
453
+ self.messages.append({"role": "system", "content": self.system.strip()})
454
+
455
+ def add_user(self, text: str) -> None:
456
+ self.messages.append({"role": "user", "content": text})
457
+
458
+
459
+ def apply_slash_command(state: ChatLoopState, text: str) -> str | None:
460
+ """Return a status string if `text` is a slash command, else None."""
461
+
462
+ command = text.strip()
463
+ lowered = command.lower()
464
+ if lowered in {"/exit", "/quit"}:
465
+ return "exit"
466
+ if lowered == "/reset":
467
+ state.reset()
468
+ return "history cleared"
469
+ if lowered == "/think":
470
+ if state.messages:
471
+ return "thinking can only be changed on a fresh conversation (/reset first)"
472
+ state.enable_thinking = True
473
+ return "thinking on — next assistant turn is prefixed with <|think|>"
474
+ if lowered in {"/no_think", "/nothink"}:
475
+ if state.messages:
476
+ return "thinking can only be changed on a fresh conversation (/reset first)"
477
+ state.enable_thinking = False
478
+ return "thinking off — next assistant turn is prefixed with <|no_think|>"
479
+ if lowered.startswith("/system"):
480
+ rest = command[len("/system") :].strip()
481
+ state.system = rest
482
+ state.reset()
483
+ return "system prompt updated" if rest else "system prompt cleared"
484
+ return None
tiny_gdn/code_exec.py ADDED
@@ -0,0 +1,497 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Restricted stateful Python for Nemotron Cascade-2 math tool calls.
2
+
3
+ Tercet-R saw `stateful_python_code_exec` on `math/math_tool.jsonl` during
4
+ the Cascade-2 SFT stage. Observations are the Jupyter-style stdout / last
5
+ expression value, not a JSON envelope.
6
+
7
+ The worker process only allows a math-oriented import whitelist and rejects
8
+ dunder access, so a tool call cannot read the filesystem or start a shell.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+ import io
15
+ import json
16
+ import math
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ import threading
21
+ from collections.abc import Mapping
22
+ from pathlib import Path
23
+ from queue import Empty, Queue
24
+ from typing import Any
25
+
26
+
27
+ NEMOTRON_PYTHON_EXEC_TOOL: dict[str, Any] = {
28
+ "type": "function",
29
+ "function": {
30
+ "name": "stateful_python_code_exec",
31
+ "description": (
32
+ "Call this function to execute Python code in a stateful Jupyter "
33
+ "notebook environment. Python will respond with the output of the "
34
+ "execution or time out after 120.0 seconds."
35
+ ),
36
+ "parameters": {
37
+ "type": "object",
38
+ "properties": {
39
+ "code": {
40
+ "type": "string",
41
+ "description": "Code to execute",
42
+ }
43
+ },
44
+ "required": ["code"],
45
+ },
46
+ },
47
+ }
48
+
49
+ CALCULATOR_TOOL: dict[str, Any] = {
50
+ "type": "function",
51
+ "function": {
52
+ "name": "calculator",
53
+ "description": "Evaluate a math expression and return the numeric result.",
54
+ "parameters": {
55
+ "type": "object",
56
+ "properties": {
57
+ "expression": {
58
+ "type": "string",
59
+ "description": "Math expression, for example 12.5 * (3 + 4)",
60
+ }
61
+ },
62
+ "required": ["expression"],
63
+ },
64
+ },
65
+ }
66
+
67
+ PYTHON_EXEC_TOOL_NAMES = frozenset(
68
+ {
69
+ "stateful_python_code_exec",
70
+ "python_code_exec",
71
+ "code_interpreter",
72
+ "python_exec",
73
+ "python",
74
+ }
75
+ )
76
+ CALCULATOR_TOOL_NAMES = frozenset({"calculator", "calc"})
77
+ DEFAULT_EXEC_TIMEOUT_SECONDS = 30.0
78
+ _SRC_ROOT = Path(__file__).resolve().parents[1]
79
+
80
+ _session_lock = threading.Lock()
81
+ _default_session: StatefulPythonSession | None = None
82
+
83
+
84
+ def _normalize_tool_name(name: str) -> str:
85
+ return name.strip().lower().replace("-", "_")
86
+
87
+
88
+ def is_python_exec_tool_name(name: str) -> bool:
89
+ return _normalize_tool_name(name) in PYTHON_EXEC_TOOL_NAMES
90
+
91
+
92
+ def is_calculator_tool_name(name: str) -> bool:
93
+ return _normalize_tool_name(name) in CALCULATOR_TOOL_NAMES
94
+
95
+
96
+ def is_auto_math_tool_name(name: str) -> bool:
97
+ return is_python_exec_tool_name(name) or is_calculator_tool_name(name)
98
+
99
+
100
+ def code_from_arguments(arguments: Mapping[str, Any] | None) -> str:
101
+ if not arguments:
102
+ return ""
103
+ for key in ("code", "expression", "expr", "source"):
104
+ raw = arguments.get(key)
105
+ if isinstance(raw, str) and raw.strip():
106
+ return raw
107
+ if raw is not None and key != "code":
108
+ return str(raw)
109
+ return ""
110
+
111
+
112
+ class CodeExecError(ValueError):
113
+ """Rejected or failed tool code."""
114
+
115
+
116
+ class StatefulPythonSession:
117
+ """One long-lived restricted interpreter, matching the training tool."""
118
+
119
+ def __init__(self, *, timeout_seconds: float = DEFAULT_EXEC_TIMEOUT_SECONDS) -> None:
120
+ self.timeout_seconds = timeout_seconds
121
+ self._lock = threading.Lock()
122
+ self._process: subprocess.Popen[str] | None = None
123
+
124
+ def close(self) -> None:
125
+ with self._lock:
126
+ self._kill_locked()
127
+
128
+ def reset(self) -> None:
129
+ with self._lock:
130
+ self._kill_locked()
131
+
132
+ def run(self, code: str) -> str:
133
+ text = code.strip()
134
+ if not text:
135
+ raise CodeExecError("code is empty")
136
+ with self._lock:
137
+ return self._run_locked(text)
138
+
139
+ def _run_locked(self, code: str) -> str:
140
+ process = self._ensure_process_locked()
141
+ try:
142
+ process.stdin.write(json.dumps({"code": code}, ensure_ascii=False) + "\n")
143
+ process.stdin.flush()
144
+ line = self._readline_locked(process, self.timeout_seconds)
145
+ except (BrokenPipeError, OSError) as error:
146
+ self._kill_locked()
147
+ raise CodeExecError(f"python worker died: {error}") from error
148
+ if not line:
149
+ stderr = ""
150
+ if process.stderr is not None:
151
+ try:
152
+ stderr = process.stderr.read()
153
+ except OSError:
154
+ stderr = ""
155
+ self._kill_locked()
156
+ detail = stderr.strip() or "python worker closed stdout"
157
+ raise CodeExecError(detail)
158
+ try:
159
+ payload = json.loads(line)
160
+ except json.JSONDecodeError as error:
161
+ self._kill_locked()
162
+ raise CodeExecError(f"python worker returned invalid JSON: {line!r}") from error
163
+ if not isinstance(payload, dict):
164
+ raise CodeExecError("python worker returned a non-object")
165
+ output = str(payload.get("output") or "")
166
+ if payload.get("ok") is True:
167
+ return output if output else "None"
168
+ raise CodeExecError(output or "execution failed")
169
+
170
+ def _readline_locked(self, process: subprocess.Popen[str], timeout: float) -> str:
171
+ if process.stdout is None:
172
+ raise CodeExecError("python worker has no stdout")
173
+ lines: Queue[str] = Queue()
174
+
175
+ def _read() -> None:
176
+ lines.put(process.stdout.readline() if process.stdout is not None else "")
177
+
178
+ reader = threading.Thread(target=_read, daemon=True)
179
+ reader.start()
180
+ reader.join(timeout)
181
+ if reader.is_alive():
182
+ self._kill_locked()
183
+ raise CodeExecError(f"timed out after {timeout:g}s")
184
+ try:
185
+ return lines.get_nowait()
186
+ except Empty:
187
+ return ""
188
+
189
+ def _ensure_process_locked(self) -> subprocess.Popen[str]:
190
+ process = self._process
191
+ if process is not None and process.poll() is None:
192
+ return process
193
+ env = os.environ.copy()
194
+ pythonpath = env.get("PYTHONPATH", "")
195
+ env["PYTHONPATH"] = (
196
+ str(_SRC_ROOT) if not pythonpath else f"{_SRC_ROOT}{os.pathsep}{pythonpath}"
197
+ )
198
+ self._process = subprocess.Popen(
199
+ [sys.executable, "-m", "tiny_gdn.code_exec"],
200
+ stdin=subprocess.PIPE,
201
+ stdout=subprocess.PIPE,
202
+ stderr=subprocess.PIPE,
203
+ text=True,
204
+ bufsize=1,
205
+ env=env,
206
+ )
207
+ if self._process.stdin is None or self._process.stdout is None:
208
+ self._kill_locked()
209
+ raise CodeExecError("failed to start python worker")
210
+ return self._process
211
+
212
+ def _kill_locked(self) -> None:
213
+ process = self._process
214
+ self._process = None
215
+ if process is None:
216
+ return
217
+ try:
218
+ process.kill()
219
+ process.wait(timeout=2)
220
+ except (OSError, subprocess.TimeoutExpired):
221
+ pass
222
+
223
+
224
+ def default_python_session() -> StatefulPythonSession:
225
+ global _default_session
226
+ with _session_lock:
227
+ if _default_session is None:
228
+ _default_session = StatefulPythonSession()
229
+ return _default_session
230
+
231
+
232
+ def reset_default_python_session() -> None:
233
+ global _default_session
234
+ with _session_lock:
235
+ session = _default_session
236
+ _default_session = None
237
+ if session is not None:
238
+ session.close()
239
+
240
+
241
+ def execute_calculator(expression: str) -> str:
242
+ tree = ast.parse(expression, mode="eval")
243
+ _reject_unsafe_ast(tree)
244
+ value = eval( # noqa: S307 — AST already rejected unsafe nodes
245
+ compile(tree, "<calculator>", "eval"),
246
+ {"__builtins__": {}},
247
+ _calculator_namespace(),
248
+ )
249
+ return _format_result(value)
250
+
251
+
252
+ def execute_math_tool(name: str, arguments: Mapping[str, Any] | None) -> str:
253
+ source = code_from_arguments(arguments)
254
+ if is_calculator_tool_name(name) and "\n" not in source and ";" not in source:
255
+ try:
256
+ return execute_calculator(source)
257
+ except (SyntaxError, CodeExecError, TypeError, ValueError, ZeroDivisionError) as error:
258
+ raise CodeExecError(str(error)) from error
259
+ return default_python_session().run(source)
260
+
261
+
262
+ def _calculator_namespace() -> dict[str, Any]:
263
+ names = {
264
+ key: getattr(math, key)
265
+ for key in dir(math)
266
+ if not key.startswith("_")
267
+ }
268
+ names.update(
269
+ {
270
+ "abs": abs,
271
+ "min": min,
272
+ "max": max,
273
+ "round": round,
274
+ "pow": pow,
275
+ "pi": math.pi,
276
+ "e": math.e,
277
+ "tau": math.tau,
278
+ "inf": math.inf,
279
+ }
280
+ )
281
+ return names
282
+
283
+
284
+ def _reject_unsafe_ast(tree: ast.AST) -> None:
285
+ for node in ast.walk(tree):
286
+ if isinstance(node, (ast.ClassDef, ast.AsyncFunctionDef)):
287
+ raise CodeExecError("class definitions are blocked")
288
+ if isinstance(node, ast.Attribute) and node.attr.startswith("_"):
289
+ raise CodeExecError("dunder attribute access is blocked")
290
+ if isinstance(node, ast.Name) and node.id.startswith("_"):
291
+ raise CodeExecError("dunder names are blocked")
292
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
293
+ if node.func.id in _BANNED_CALLS:
294
+ raise CodeExecError(f"{node.func.id}() is blocked")
295
+
296
+
297
+ _BANNED_CALLS = frozenset(
298
+ {
299
+ "eval",
300
+ "exec",
301
+ "compile",
302
+ "open",
303
+ "input",
304
+ "breakpoint",
305
+ "getattr",
306
+ "setattr",
307
+ "delattr",
308
+ "globals",
309
+ "locals",
310
+ "vars",
311
+ "dir",
312
+ "help",
313
+ "__import__",
314
+ "memoryview",
315
+ "exit",
316
+ "quit",
317
+ }
318
+ )
319
+
320
+
321
+ def _format_result(value: Any) -> str:
322
+ if value is None:
323
+ return "None"
324
+ if isinstance(value, bool):
325
+ return str(value)
326
+ if isinstance(value, float):
327
+ if value.is_integer() and abs(value) < 1e15:
328
+ return str(int(value))
329
+ return format(value, ".12g")
330
+ return str(value)
331
+
332
+
333
+ _ALLOWED_IMPORT_ROOTS = frozenset(
334
+ {
335
+ "cmath",
336
+ "collections",
337
+ "copy",
338
+ "decimal",
339
+ "fractions",
340
+ "functools",
341
+ "itertools",
342
+ "json",
343
+ "math",
344
+ "mpmath",
345
+ "numbers",
346
+ "numpy",
347
+ "operator",
348
+ "re",
349
+ "statistics",
350
+ "string",
351
+ "sympy",
352
+ "textwrap",
353
+ "unicodedata",
354
+ }
355
+ )
356
+
357
+ _WORKER_NAMESPACE: dict[str, Any] | None = None
358
+
359
+
360
+ def _allowed_import(
361
+ name: str,
362
+ globals: dict[str, Any] | None = None,
363
+ locals: dict[str, Any] | None = None,
364
+ fromlist: tuple[str, ...] = (),
365
+ level: int = 0,
366
+ ) -> Any:
367
+ root = name.split(".")[0]
368
+ if root not in _ALLOWED_IMPORT_ROOTS:
369
+ raise ImportError(f"import of {name!r} is blocked")
370
+ return __import__(name, globals, locals, fromlist, level)
371
+
372
+
373
+ def _worker_namespace() -> dict[str, Any]:
374
+ global _WORKER_NAMESPACE
375
+ if _WORKER_NAMESPACE is None:
376
+ builtins = {
377
+ "abs": abs,
378
+ "all": all,
379
+ "any": any,
380
+ "bin": bin,
381
+ "bool": bool,
382
+ "bytes": bytes,
383
+ "chr": chr,
384
+ "complex": complex,
385
+ "dict": dict,
386
+ "divmod": divmod,
387
+ "enumerate": enumerate,
388
+ "filter": filter,
389
+ "float": float,
390
+ "format": format,
391
+ "frozenset": frozenset,
392
+ "hex": hex,
393
+ "int": int,
394
+ "isinstance": isinstance,
395
+ "issubclass": issubclass,
396
+ "iter": iter,
397
+ "len": len,
398
+ "list": list,
399
+ "map": map,
400
+ "max": max,
401
+ "min": min,
402
+ "next": next,
403
+ "oct": oct,
404
+ "ord": ord,
405
+ "pow": pow,
406
+ "print": print,
407
+ "range": range,
408
+ "repr": repr,
409
+ "reversed": reversed,
410
+ "round": round,
411
+ "set": set,
412
+ "slice": slice,
413
+ "sorted": sorted,
414
+ "str": str,
415
+ "sum": sum,
416
+ "tuple": tuple,
417
+ "zip": zip,
418
+ "True": True,
419
+ "False": False,
420
+ "None": None,
421
+ "__import__": _allowed_import,
422
+ }
423
+ _WORKER_NAMESPACE = {
424
+ "__builtins__": builtins,
425
+ "__name__": "__tool__",
426
+ "math": math,
427
+ }
428
+ return _WORKER_NAMESPACE
429
+
430
+
431
+ def run_cell(code: str) -> str:
432
+ tree = ast.parse(code)
433
+ _reject_unsafe_import_roots(tree)
434
+ _reject_unsafe_ast(tree)
435
+ namespace = _worker_namespace()
436
+ buffer = io.StringIO()
437
+ previous = sys.stdout
438
+ sys.stdout = buffer
439
+ try:
440
+ if tree.body and isinstance(tree.body[-1], ast.Expr):
441
+ body = tree.body[:-1]
442
+ last = tree.body[-1]
443
+ if body:
444
+ exec( # noqa: S102
445
+ compile(ast.Module(body, type_ignores=[]), "<tool>", "exec"),
446
+ namespace,
447
+ namespace,
448
+ )
449
+ value = eval( # noqa: S307
450
+ compile(ast.Expression(last.value), "<tool>", "eval"),
451
+ namespace,
452
+ namespace,
453
+ )
454
+ printed = buffer.getvalue()
455
+ if value is None:
456
+ return printed if printed else "None"
457
+ rendered = _format_result(value)
458
+ return f"{printed}{rendered}" if printed else rendered
459
+ exec(compile(tree, "<tool>", "exec"), namespace, namespace) # noqa: S102
460
+ printed = buffer.getvalue()
461
+ return printed if printed else "None"
462
+ finally:
463
+ sys.stdout = previous
464
+
465
+
466
+ def _reject_unsafe_import_roots(tree: ast.AST) -> None:
467
+ for node in ast.walk(tree):
468
+ if isinstance(node, ast.Import):
469
+ for alias in node.names:
470
+ root = alias.name.split(".")[0]
471
+ if root not in _ALLOWED_IMPORT_ROOTS:
472
+ raise CodeExecError(f"import of {alias.name!r} is blocked")
473
+ elif isinstance(node, ast.ImportFrom):
474
+ root = (node.module or "").split(".")[0]
475
+ if root not in _ALLOWED_IMPORT_ROOTS:
476
+ raise CodeExecError(f"import of {node.module!r} is blocked")
477
+
478
+
479
+ def _worker_loop() -> None:
480
+ for line in sys.stdin:
481
+ line = line.strip()
482
+ if not line:
483
+ continue
484
+ try:
485
+ request = json.loads(line)
486
+ code = str(request.get("code") or "")
487
+ output = run_cell(code)
488
+ sys.stdout.write(json.dumps({"ok": True, "output": output}, ensure_ascii=False) + "\n")
489
+ except Exception as error:
490
+ sys.stdout.write(
491
+ json.dumps({"ok": False, "output": str(error)}, ensure_ascii=False) + "\n"
492
+ )
493
+ sys.stdout.flush()
494
+
495
+
496
+ if __name__ == "__main__":
497
+ _worker_loop()
tiny_gdn/config.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict, dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class TinyGDNConfig:
11
+ architecture: str = "TinyGDNForCausalLM"
12
+ model_type: str = "tiny_gdn"
13
+
14
+ vocab_size: int = 49_152
15
+ # Deep-thin sizing is deliberate: controlled sub-billion studies find
16
+ # depth materially more valuable than width around the 125M-150M scale.
17
+ hidden_size: int = 512
18
+ intermediate_size: int = 1_472
19
+ num_hidden_layers: int = 32
20
+
21
+ num_attention_heads: int = 4
22
+ num_key_value_heads: int = 1
23
+ attention_head_dim: int = 128
24
+ full_attention_interval: int = 4
25
+ attention_dropout: float = 0.0
26
+ partial_rotary_factor: float = 0.5
27
+ rope_theta: float = 1_000_000.0
28
+
29
+ linear_num_heads: int = 4
30
+ linear_num_value_heads: int = 4
31
+ linear_head_dim: int = 128
32
+ linear_expand_v: float = 1.0
33
+ linear_conv_kernel_dim: int = 4
34
+ allow_negative_eigenvalues: bool = False
35
+
36
+ max_position_embeddings: int = 32_768
37
+ training_sequence_length: int = 2_048
38
+ rms_norm_eps: float = 1e-6
39
+ initializer_range: float = 0.02
40
+ tie_word_embeddings: bool = True
41
+ shared_layer_indices: tuple[int, ...] = ()
42
+
43
+ # MTP is an opt-in ablation at this scale; static MTP is not assumed to
44
+ # improve a 150M model without a controlled pilot.
45
+ mtp_num_heads: int = 0
46
+ mtp_adapter_rank: int = 128
47
+ mtp_loss_weight: float = 0.0
48
+
49
+ bos_token_id: int = 0
50
+ eos_token_id: int = 1
51
+ pad_token_id: int = 2
52
+ unk_token_id: int = 3
53
+
54
+ def __post_init__(self) -> None:
55
+ if self.vocab_size <= 0 or self.vocab_size > 65_536:
56
+ raise ValueError("vocab_size must fit the uint16 token dataset")
57
+ if self.hidden_size != self.num_attention_heads * self.attention_head_dim:
58
+ raise ValueError("hidden_size must equal num_attention_heads * attention_head_dim")
59
+ if self.hidden_size != self.linear_num_heads * self.linear_head_dim:
60
+ raise ValueError("hidden_size must equal linear_num_heads * linear_head_dim")
61
+ if self.linear_num_value_heads < self.linear_num_heads:
62
+ raise ValueError("linear_num_value_heads must be at least linear_num_heads")
63
+ if self.linear_num_value_heads % self.linear_num_heads != 0:
64
+ raise ValueError("linear_num_value_heads must be divisible by linear_num_heads")
65
+ if self.num_attention_heads % self.num_key_value_heads != 0:
66
+ raise ValueError("num_attention_heads must be divisible by num_key_value_heads")
67
+ if self.num_hidden_layers % self.full_attention_interval != 0:
68
+ raise ValueError("num_hidden_layers must be divisible by full_attention_interval")
69
+ if not 0.0 < self.partial_rotary_factor <= 1.0:
70
+ raise ValueError("partial_rotary_factor must be in (0, 1]")
71
+ rotary_dim = int(self.attention_head_dim * self.partial_rotary_factor)
72
+ if rotary_dim <= 0 or rotary_dim % 2:
73
+ raise ValueError("The partial rotary dimension must be positive and even")
74
+ if self.training_sequence_length > self.max_position_embeddings:
75
+ raise ValueError("training_sequence_length exceeds max_position_embeddings")
76
+ if len(set(self.shared_layer_indices)) != len(self.shared_layer_indices):
77
+ raise ValueError("shared_layer_indices must be unique")
78
+ if any(
79
+ index < 0 or index >= self.num_hidden_layers
80
+ for index in self.shared_layer_indices
81
+ ):
82
+ raise ValueError("shared_layer_indices contains an invalid layer")
83
+ if self.mtp_num_heads < 0:
84
+ raise ValueError("mtp_num_heads cannot be negative")
85
+ if self.mtp_num_heads and self.mtp_adapter_rank <= 0:
86
+ raise ValueError("mtp_adapter_rank must be positive when MTP is enabled")
87
+ if not 0.0 <= self.mtp_loss_weight <= 1.0:
88
+ raise ValueError("mtp_loss_weight must be between zero and one")
89
+ for token_id in (
90
+ self.bos_token_id,
91
+ self.eos_token_id,
92
+ self.pad_token_id,
93
+ self.unk_token_id,
94
+ ):
95
+ if not 0 <= token_id < self.vocab_size:
96
+ raise ValueError(f"Special token ID {token_id} is outside the vocabulary")
97
+
98
+ @property
99
+ def layer_types(self) -> tuple[str, ...]:
100
+ return tuple(
101
+ "full_attention" if (index + 1) % self.full_attention_interval == 0 else "gdn2"
102
+ for index in range(self.num_hidden_layers)
103
+ )
104
+
105
+ @property
106
+ def rotary_dim(self) -> int:
107
+ return int(self.attention_head_dim * self.partial_rotary_factor)
108
+
109
+ @property
110
+ def effective_num_layers(self) -> int:
111
+ return self.num_hidden_layers + len(self.shared_layer_indices)
112
+
113
+ def to_dict(self) -> dict[str, Any]:
114
+ payload = asdict(self)
115
+ payload["layer_types"] = list(self.layer_types)
116
+ return payload
117
+
118
+ def save_json(self, path: Path) -> None:
119
+ path.parent.mkdir(parents=True, exist_ok=True)
120
+ path.write_text(
121
+ json.dumps(self.to_dict(), indent=2, sort_keys=True) + "\n",
122
+ encoding="utf-8",
123
+ )
124
+
125
+ @classmethod
126
+ def from_json(cls, path: Path) -> TinyGDNConfig:
127
+ payload = json.loads(path.read_text(encoding="utf-8"))
128
+ payload.pop("layer_types", None)
129
+ if "shared_layer_indices" in payload:
130
+ payload["shared_layer_indices"] = tuple(payload["shared_layer_indices"])
131
+ return cls(**payload)
tiny_gdn/detokenize.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Byte-accurate incremental decode for GPT-2-style byte-level BPE.
2
+
3
+ `tokenizer.decode(ids)` UTF-8-replaces incomplete sequences. Streaming that
4
+ as a string prefix then breaks on the next byte: decode([20492]) is a
5
+ replacement character and decode([20492, 294]) is `` ≈``, so the
6
+ accumulated text is no longer a prefix and the UI re-emits the whole
7
+ think block.
8
+
9
+ This decoder concatenates raw BPE bytes and only emits complete UTF-8
10
+ characters. The completing byte of ``≈`` yields `` ≈``; nothing is rewound.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Iterable, Mapping, Sequence
16
+ from typing import Any
17
+
18
+
19
+ def gpt2_bytes_to_unicode() -> dict[int, str]:
20
+ raw = (
21
+ list(range(ord("!"), ord("~") + 1))
22
+ + list(range(ord("¡"), ord("¬") + 1))
23
+ + list(range(ord("®"), ord("ÿ") + 1))
24
+ )
25
+ mapped = raw[:]
26
+ extra = 0
27
+ for byte in range(256):
28
+ if byte not in raw:
29
+ raw.append(byte)
30
+ mapped.append(256 + extra)
31
+ extra += 1
32
+ return dict(zip(raw, [chr(code) for code in mapped], strict=True))
33
+
34
+
35
+ def unicode_to_gpt2_bytes() -> dict[str, int]:
36
+ return {char: byte for byte, char in gpt2_bytes_to_unicode().items()}
37
+
38
+
39
+ def utf8_char_length(lead: int) -> int | None:
40
+ if lead < 0x80:
41
+ return 1
42
+ if 0xC2 <= lead <= 0xDF:
43
+ return 2
44
+ if 0xE0 <= lead <= 0xEF:
45
+ return 3
46
+ if 0xF0 <= lead <= 0xF4:
47
+ return 4
48
+ return None
49
+
50
+
51
+ def split_complete_utf8(buffer: bytes) -> tuple[str, bytes]:
52
+ """Return (decoded prefix, leftover incomplete or invalid-lead bytes)."""
53
+ index = 0
54
+ end = len(buffer)
55
+ while index < end:
56
+ size = utf8_char_length(buffer[index])
57
+ if size is None:
58
+ break
59
+ if index + size > end:
60
+ break
61
+ chunk = buffer[index : index + size]
62
+ try:
63
+ chunk.decode("utf-8")
64
+ except UnicodeDecodeError:
65
+ break
66
+ index += size
67
+ if index == 0:
68
+ return "", buffer
69
+ return buffer[:index].decode("utf-8"), buffer[index:]
70
+
71
+
72
+ def piece_to_bytes(piece: str, unicode_to_byte: Mapping[str, int]) -> bytes:
73
+ raw = bytearray()
74
+ for char in piece:
75
+ mapped = unicode_to_byte.get(char)
76
+ if mapped is None:
77
+ raw.extend(char.encode("utf-8"))
78
+ else:
79
+ raw.append(mapped)
80
+ return bytes(raw)
81
+
82
+
83
+ class IncrementalUtf8Decoder:
84
+ """Push token ids; read only complete Unicode as it becomes available."""
85
+
86
+ def __init__(
87
+ self,
88
+ tokenizer: Any,
89
+ *,
90
+ skip_special_ids: Iterable[int] | None = None,
91
+ ) -> None:
92
+ vocab = tokenizer.get_vocab()
93
+ self._id_to_piece = {tid: piece for piece, tid in vocab.items()}
94
+ self._unicode_to_byte = unicode_to_gpt2_bytes()
95
+ self._skip_special_ids = set(skip_special_ids or ())
96
+ self._pending = b""
97
+ self._text = ""
98
+
99
+ @property
100
+ def text(self) -> str:
101
+ return self._text
102
+
103
+ def reset(self) -> None:
104
+ self._pending = b""
105
+ self._text = ""
106
+
107
+ def token_bytes(self, token_id: int) -> bytes:
108
+ piece = self._id_to_piece.get(token_id, "")
109
+ return piece_to_bytes(piece, self._unicode_to_byte)
110
+
111
+ def push(self, token_id: int) -> str:
112
+ if token_id in self._skip_special_ids:
113
+ return ""
114
+ self._pending += self.token_bytes(token_id)
115
+ complete, self._pending = split_complete_utf8(self._pending)
116
+ self._text += complete
117
+ return complete
118
+
119
+ def push_many(self, token_ids: Sequence[int]) -> str:
120
+ delta = []
121
+ for token_id in token_ids:
122
+ piece = self.push(token_id)
123
+ if piece:
124
+ delta.append(piece)
125
+ return "".join(delta)
126
+
127
+ def finalize(self) -> str:
128
+ if self._pending:
129
+ self._text += self._pending.decode("utf-8", errors="replace")
130
+ self._pending = b""
131
+ return self._text
tiny_gdn/model.py ADDED
@@ -0,0 +1,683 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import torch
9
+ import torch.nn.functional as F
10
+ from safetensors.torch import load_model, save_model
11
+ from torch import nn
12
+ from torch.nn.attention import SDPBackend, sdpa_kernel
13
+ from torch.utils.checkpoint import checkpoint
14
+
15
+ from tiny_gdn.config import TinyGDNConfig
16
+
17
+ try:
18
+ # Import the module directly — `from fla.layers import GatedDeltaNet2`
19
+ # executes layers/__init__.py and eagerly loads every attention kernel.
20
+ from fla.layers.gdn2 import GatedDeltaNet2
21
+ except ImportError as import_error:
22
+ GatedDeltaNet2 = None
23
+ FLA_IMPORT_ERROR: ImportError | None = import_error
24
+ else:
25
+ FLA_IMPORT_ERROR = None
26
+
27
+
28
+ def attention_sdpa_backends(device: torch.device) -> list[SDPBackend]:
29
+ if device.type != "cuda":
30
+ return [SDPBackend.MATH]
31
+ # Blackwell (sm_120) prefers cuDNN SDPA. Flash is often missing on
32
+ # Windows/WSL builds; mem-efficient at 16k is the slow path. MATH is
33
+ # never listed on CUDA — a silent math fallback is a 10× step-time cliff.
34
+ return [
35
+ SDPBackend.CUDNN_ATTENTION,
36
+ SDPBackend.FLASH_ATTENTION,
37
+ SDPBackend.EFFICIENT_ATTENTION,
38
+ ]
39
+
40
+
41
+ @dataclass
42
+ class TinyGDNOutput:
43
+ loss: torch.Tensor | None
44
+ logits: torch.Tensor | None
45
+ main_loss: torch.Tensor | None
46
+ mtp_loss: torch.Tensor | None
47
+ z_loss: torch.Tensor | None
48
+ hidden_states: torch.Tensor | None = None
49
+ past_key_values: Any | None = None
50
+
51
+
52
+ class RMSNorm(nn.Module):
53
+ """Zero-centered RMSNorm as used by Qwen3-Next."""
54
+
55
+ def __init__(self, hidden_size: int, eps: float) -> None:
56
+ super().__init__()
57
+ self.weight = nn.Parameter(torch.zeros(hidden_size))
58
+ self.eps = eps
59
+
60
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
61
+ input_dtype = hidden_states.dtype
62
+ normalized = hidden_states.float()
63
+ normalized = normalized * torch.rsqrt(normalized.square().mean(dim=-1, keepdim=True) + self.eps)
64
+ normalized = normalized * (1.0 + self.weight.float())
65
+ return normalized.to(dtype=input_dtype)
66
+
67
+
68
+ class RotaryEmbedding(nn.Module):
69
+ def __init__(self, rotary_dim: int, rope_theta: float) -> None:
70
+ super().__init__()
71
+ inverse_frequency = 1.0 / (
72
+ rope_theta
73
+ ** (
74
+ torch.arange(0, rotary_dim, 2, dtype=torch.float32)
75
+ / rotary_dim
76
+ )
77
+ )
78
+ self.rotary_dim = rotary_dim
79
+ self.register_buffer("inverse_frequency", inverse_frequency, persistent=False)
80
+
81
+ def forward(
82
+ self,
83
+ sequence_length: int,
84
+ device: torch.device,
85
+ dtype: torch.dtype,
86
+ position_offset: int = 0,
87
+ ) -> tuple[torch.Tensor, torch.Tensor]:
88
+ positions = torch.arange(
89
+ position_offset,
90
+ position_offset + sequence_length,
91
+ device=device,
92
+ dtype=torch.float32,
93
+ )
94
+ frequencies = torch.outer(positions, self.inverse_frequency.float())
95
+ embeddings = torch.cat((frequencies, frequencies), dim=-1)
96
+ return embeddings.cos().to(dtype=dtype), embeddings.sin().to(dtype=dtype)
97
+
98
+
99
+ def rotate_half(hidden_states: torch.Tensor) -> torch.Tensor:
100
+ first, second = hidden_states.chunk(2, dim=-1)
101
+ return torch.cat((-second, first), dim=-1)
102
+
103
+
104
+ def apply_rotary_embedding(
105
+ query: torch.Tensor,
106
+ key: torch.Tensor,
107
+ cosine: torch.Tensor,
108
+ sine: torch.Tensor,
109
+ rotary_dim: int,
110
+ ) -> tuple[torch.Tensor, torch.Tensor]:
111
+ cosine = cosine[None, None, :, :]
112
+ sine = sine[None, None, :, :]
113
+ query_rotary, query_pass = query[..., :rotary_dim], query[..., rotary_dim:]
114
+ key_rotary, key_pass = key[..., :rotary_dim], key[..., rotary_dim:]
115
+ query_rotary = query_rotary * cosine + rotate_half(query_rotary) * sine
116
+ key_rotary = key_rotary * cosine + rotate_half(key_rotary) * sine
117
+ return (
118
+ torch.cat((query_rotary, query_pass), dim=-1),
119
+ torch.cat((key_rotary, key_pass), dim=-1),
120
+ )
121
+
122
+
123
+ class GatedGroupedQueryAttention(nn.Module):
124
+ """QK-normalized, partially rotary GQA with a learned sigmoid output gate."""
125
+
126
+ def __init__(self, config: TinyGDNConfig) -> None:
127
+ super().__init__()
128
+ self.num_heads = config.num_attention_heads
129
+ self.num_key_value_heads = config.num_key_value_heads
130
+ self.head_dim = config.attention_head_dim
131
+ self.rotary_dim = config.rotary_dim
132
+ self.dropout = config.attention_dropout
133
+
134
+ query_size = self.num_heads * self.head_dim
135
+ key_value_size = self.num_key_value_heads * self.head_dim
136
+ self.q_gate_proj = nn.Linear(config.hidden_size, query_size * 2, bias=False)
137
+ self.k_proj = nn.Linear(config.hidden_size, key_value_size, bias=False)
138
+ self.v_proj = nn.Linear(config.hidden_size, key_value_size, bias=False)
139
+ self.o_proj = nn.Linear(query_size, config.hidden_size, bias=False)
140
+ self.q_norm = RMSNorm(self.head_dim, config.rms_norm_eps)
141
+ self.k_norm = RMSNorm(self.head_dim, config.rms_norm_eps)
142
+ self.rotary = RotaryEmbedding(self.rotary_dim, config.rope_theta)
143
+
144
+ def _attention_mask(
145
+ self,
146
+ attention_mask: torch.Tensor | None,
147
+ sequence_length: int,
148
+ device: torch.device,
149
+ ) -> torch.Tensor | None:
150
+ if attention_mask is None:
151
+ return None
152
+ if attention_mask.ndim != 2:
153
+ raise ValueError("attention_mask must have shape [batch, sequence]")
154
+ if attention_mask.shape[1] != sequence_length:
155
+ raise ValueError("attention_mask sequence length does not match input")
156
+
157
+ causal = torch.ones(
158
+ sequence_length,
159
+ sequence_length,
160
+ dtype=torch.bool,
161
+ device=device,
162
+ ).tril()
163
+ valid_keys = attention_mask[:, None, None, :].to(dtype=torch.bool, device=device)
164
+ return causal[None, None, :, :] & valid_keys
165
+
166
+ def forward(
167
+ self,
168
+ hidden_states: torch.Tensor,
169
+ attention_mask: torch.Tensor | None = None,
170
+ past_key_value: tuple[torch.Tensor, torch.Tensor] | None = None,
171
+ ) -> tuple[torch.Tensor, tuple[torch.Tensor, torch.Tensor] | None]:
172
+ batch_size, sequence_length, _ = hidden_states.shape
173
+ past_len = 0 if past_key_value is None else past_key_value[0].shape[2]
174
+ query_and_gate = self.q_gate_proj(hidden_states)
175
+ query, output_gate = query_and_gate.chunk(2, dim=-1)
176
+
177
+ query = query.view(batch_size, sequence_length, self.num_heads, self.head_dim)
178
+ key = self.k_proj(hidden_states).view(
179
+ batch_size,
180
+ sequence_length,
181
+ self.num_key_value_heads,
182
+ self.head_dim,
183
+ )
184
+ value = self.v_proj(hidden_states).view(
185
+ batch_size,
186
+ sequence_length,
187
+ self.num_key_value_heads,
188
+ self.head_dim,
189
+ )
190
+
191
+ query = self.q_norm(query).transpose(1, 2)
192
+ key = self.k_norm(key).transpose(1, 2)
193
+ value = value.transpose(1, 2)
194
+
195
+ cosine, sine = self.rotary(
196
+ sequence_length,
197
+ device=hidden_states.device,
198
+ dtype=query.dtype,
199
+ position_offset=past_len,
200
+ )
201
+ query, key = apply_rotary_embedding(
202
+ query,
203
+ key,
204
+ cosine,
205
+ sine,
206
+ rotary_dim=self.rotary_dim,
207
+ )
208
+ if past_key_value is not None:
209
+ key = torch.cat([past_key_value[0], key], dim=2)
210
+ value = torch.cat([past_key_value[1], value], dim=2)
211
+ present = (key, value)
212
+
213
+ kv_len = key.shape[2]
214
+ if attention_mask is not None and past_key_value is None:
215
+ sdpa_mask = self._attention_mask(
216
+ attention_mask,
217
+ sequence_length,
218
+ hidden_states.device,
219
+ )
220
+ is_causal = False
221
+ elif past_key_value is not None and sequence_length == 1:
222
+ # Decode step: query attends to the cached key/value prefix. Preserve
223
+ # the prefill padding mask when decoding a left-padded prompt batch.
224
+ sdpa_mask = (
225
+ None
226
+ if attention_mask is None
227
+ else attention_mask[:, None, None, :].to(
228
+ dtype=torch.bool,
229
+ device=hidden_states.device,
230
+ )
231
+ )
232
+ is_causal = False
233
+ elif past_key_value is not None:
234
+ # Prefill chunk with cache — build causal mask over kv_len.
235
+ q_idx = torch.arange(
236
+ past_len, past_len + sequence_length, device=hidden_states.device
237
+ )[:, None]
238
+ k_idx = torch.arange(kv_len, device=hidden_states.device)[None, :]
239
+ sdpa_mask = (k_idx <= q_idx)[None, None, :, :]
240
+ is_causal = False
241
+ else:
242
+ sdpa_mask = None
243
+ is_causal = True
244
+ sdpa_options = {
245
+ "attn_mask": sdpa_mask,
246
+ "dropout_p": self.dropout if self.training else 0.0,
247
+ "is_causal": is_causal,
248
+ "enable_gqa": True,
249
+ }
250
+ with sdpa_kernel(attention_sdpa_backends(query.device)):
251
+ attention_output = F.scaled_dot_product_attention(
252
+ query,
253
+ key,
254
+ value,
255
+ **sdpa_options,
256
+ )
257
+ attention_output = attention_output.transpose(1, 2).reshape(
258
+ batch_size,
259
+ sequence_length,
260
+ -1,
261
+ )
262
+ attention_output = attention_output * torch.sigmoid(output_gate)
263
+ return self.o_proj(attention_output), present
264
+
265
+
266
+ class SwiGLU(nn.Module):
267
+ def __init__(self, config: TinyGDNConfig) -> None:
268
+ super().__init__()
269
+ self.gate_up_proj = nn.Linear(
270
+ config.hidden_size,
271
+ config.intermediate_size * 2,
272
+ bias=False,
273
+ )
274
+ self.down_proj = nn.Linear(
275
+ config.intermediate_size,
276
+ config.hidden_size,
277
+ bias=False,
278
+ )
279
+
280
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
281
+ gate, up = self.gate_up_proj(hidden_states).chunk(2, dim=-1)
282
+ return self.down_proj(F.silu(gate) * up)
283
+
284
+
285
+ class TinyGDNBlock(nn.Module):
286
+ def __init__(self, config: TinyGDNConfig, layer_index: int) -> None:
287
+ super().__init__()
288
+ layer_type = config.layer_types[layer_index]
289
+ self.layer_type = layer_type
290
+ self.token_mixer_norm = RMSNorm(config.hidden_size, config.rms_norm_eps)
291
+ self.mlp_norm = RMSNorm(config.hidden_size, config.rms_norm_eps)
292
+
293
+ if layer_type == "gdn2":
294
+ if GatedDeltaNet2 is None:
295
+ raise ImportError(
296
+ "Gated DeltaNet-2 requires the pinned flash-linear-attention dependency"
297
+ ) from FLA_IMPORT_ERROR
298
+ self.token_mixer = GatedDeltaNet2(
299
+ hidden_size=config.hidden_size,
300
+ expand_v=config.linear_expand_v,
301
+ head_dim=config.linear_head_dim,
302
+ num_heads=config.linear_num_heads,
303
+ num_v_heads=config.linear_num_value_heads,
304
+ mode="chunk",
305
+ use_short_conv=True,
306
+ allow_neg_eigval=config.allow_negative_eigenvalues,
307
+ conv_size=config.linear_conv_kernel_dim,
308
+ conv_bias=False,
309
+ layer_idx=layer_index,
310
+ norm_eps=config.rms_norm_eps,
311
+ )
312
+ elif layer_type == "full_attention":
313
+ self.token_mixer = GatedGroupedQueryAttention(config)
314
+ else:
315
+ raise ValueError(f"Unsupported layer type: {layer_type}")
316
+
317
+ self.mlp = SwiGLU(config)
318
+
319
+ def forward(
320
+ self,
321
+ hidden_states: torch.Tensor,
322
+ attention_mask: torch.Tensor | None = None,
323
+ *,
324
+ past_key_values: Any | None = None,
325
+ past_key_value: tuple[torch.Tensor, torch.Tensor] | None = None,
326
+ use_cache: bool = False,
327
+ ) -> tuple[torch.Tensor, Any]:
328
+ residual = hidden_states
329
+ normalized = self.token_mixer_norm(hidden_states)
330
+ present: Any = None
331
+ if self.layer_type == "gdn2":
332
+ mixed, _, past_key_values = self.token_mixer(
333
+ normalized,
334
+ attention_mask=attention_mask,
335
+ past_key_values=past_key_values,
336
+ use_cache=use_cache,
337
+ )
338
+ present = past_key_values
339
+ else:
340
+ mixed, present = self.token_mixer(
341
+ normalized,
342
+ attention_mask=attention_mask,
343
+ past_key_value=past_key_value,
344
+ )
345
+ if not use_cache:
346
+ present = None
347
+ hidden_states = residual + mixed
348
+ hidden_states = hidden_states + self.mlp(self.mlp_norm(hidden_states))
349
+ return hidden_states, present
350
+
351
+
352
+ class MultiTokenPredictionAdapter(nn.Module):
353
+ """A lightweight residual adapter for one additional prediction horizon."""
354
+
355
+ def __init__(self, config: TinyGDNConfig) -> None:
356
+ super().__init__()
357
+ self.norm = RMSNorm(config.hidden_size, config.rms_norm_eps)
358
+ self.down_proj = nn.Linear(
359
+ config.hidden_size,
360
+ config.mtp_adapter_rank,
361
+ bias=False,
362
+ )
363
+ self.up_proj = nn.Linear(
364
+ config.mtp_adapter_rank,
365
+ config.hidden_size,
366
+ bias=False,
367
+ )
368
+
369
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
370
+ adapted = self.up_proj(F.silu(self.down_proj(self.norm(hidden_states))))
371
+ return hidden_states + adapted
372
+
373
+
374
+ class TinyGDNForCausalLM(nn.Module):
375
+ def __init__(self, config: TinyGDNConfig) -> None:
376
+ super().__init__()
377
+ self.config = config
378
+ self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size)
379
+ self.layers = nn.ModuleList(
380
+ TinyGDNBlock(config, layer_index)
381
+ for layer_index in range(config.num_hidden_layers)
382
+ )
383
+ self.final_norm = RMSNorm(config.hidden_size, config.rms_norm_eps)
384
+ self.mtp_adapters = nn.ModuleList(
385
+ MultiTokenPredictionAdapter(config)
386
+ for _ in range(config.mtp_num_heads)
387
+ )
388
+ self.gradient_checkpointing = False
389
+
390
+ self.apply(self._initialize_module)
391
+ self._initialize_residual_projections()
392
+
393
+ def _initialize_module(self, module: nn.Module) -> None:
394
+ if isinstance(module, nn.Linear):
395
+ nn.init.normal_(
396
+ module.weight,
397
+ mean=0.0,
398
+ std=self.config.initializer_range,
399
+ )
400
+ if module.bias is not None:
401
+ nn.init.zeros_(module.bias)
402
+ elif isinstance(module, nn.Embedding):
403
+ nn.init.normal_(
404
+ module.weight,
405
+ mean=0.0,
406
+ std=self.config.initializer_range,
407
+ )
408
+
409
+ def _initialize_residual_projections(self) -> None:
410
+ residual_std = self.config.initializer_range / math.sqrt(
411
+ 2 * self.config.num_hidden_layers
412
+ )
413
+ for layer in self.layers:
414
+ nn.init.normal_(
415
+ layer.token_mixer.o_proj.weight,
416
+ mean=0.0,
417
+ std=residual_std,
418
+ )
419
+ nn.init.normal_(
420
+ layer.mlp.down_proj.weight,
421
+ mean=0.0,
422
+ std=residual_std,
423
+ )
424
+ for adapter in self.mtp_adapters:
425
+ nn.init.normal_(adapter.up_proj.weight, mean=0.0, std=residual_std)
426
+
427
+ def enable_gradient_checkpointing(self, enabled: bool = True) -> None:
428
+ self.gradient_checkpointing = enabled
429
+
430
+ def project_to_vocabulary(self, hidden_states: torch.Tensor) -> torch.Tensor:
431
+ return F.linear(hidden_states, self.embed_tokens.weight)
432
+
433
+ def _run_layer(
434
+ self,
435
+ layer: TinyGDNBlock,
436
+ hidden_states: torch.Tensor,
437
+ attention_mask: torch.Tensor | None,
438
+ *,
439
+ past_key_values: Any | None = None,
440
+ past_key_value: tuple[torch.Tensor, torch.Tensor] | None = None,
441
+ use_cache: bool = False,
442
+ ) -> tuple[torch.Tensor, Any]:
443
+ if self.gradient_checkpointing and self.training:
444
+ hidden_states, present = checkpoint(
445
+ layer,
446
+ hidden_states,
447
+ attention_mask,
448
+ use_reentrant=False,
449
+ )
450
+ return hidden_states, present
451
+ return layer(
452
+ hidden_states,
453
+ attention_mask,
454
+ past_key_values=past_key_values,
455
+ past_key_value=past_key_value,
456
+ use_cache=use_cache,
457
+ )
458
+
459
+ def _causal_loss(
460
+ self,
461
+ hidden_states: torch.Tensor,
462
+ labels: torch.Tensor,
463
+ target_offset: int,
464
+ adapter: nn.Module | None = None,
465
+ compute_z_loss: bool = False,
466
+ ) -> tuple[torch.Tensor, torch.Tensor | None]:
467
+ if target_offset < 0:
468
+ raise ValueError("target_offset cannot be negative")
469
+ if target_offset and hidden_states.shape[1] <= target_offset:
470
+ raise ValueError(
471
+ f"Sequence length must exceed target offset {target_offset}"
472
+ )
473
+ if target_offset:
474
+ prediction_states = hidden_states[:, :-target_offset, :]
475
+ targets = labels[:, target_offset:].contiguous()
476
+ else:
477
+ prediction_states = hidden_states
478
+ targets = labels.contiguous()
479
+ if adapter is not None:
480
+ prediction_states = adapter(prediction_states)
481
+ logits = self.project_to_vocabulary(prediction_states)
482
+ cross_entropy = F.cross_entropy(
483
+ logits.reshape(-1, self.config.vocab_size),
484
+ targets.reshape(-1),
485
+ ignore_index=-100,
486
+ )
487
+ z_loss = None
488
+ if compute_z_loss:
489
+ valid_targets = targets.ne(-100)
490
+ # Keep logits in their training dtype for logsumexp. Casting the
491
+ # full [B, S, V] tensor to fp32 materializes ~3 GiB in the autograd
492
+ # graph at 16k; upcast only the reduced [B, S] partition.
493
+ log_partition = torch.logsumexp(logits, dim=-1).float()
494
+ z_loss = log_partition.square()[valid_targets].mean()
495
+ return cross_entropy, z_loss
496
+
497
+ def forward(
498
+ self,
499
+ input_ids: torch.Tensor,
500
+ labels: torch.Tensor | None = None,
501
+ attention_mask: torch.Tensor | None = None,
502
+ past_key_values: Any | None = None,
503
+ *,
504
+ use_cache: bool = False,
505
+ return_logits: bool = True,
506
+ return_hidden_states: bool = False,
507
+ labels_are_shifted: bool = False,
508
+ include_mtp_loss: bool = True,
509
+ mtp_loss_weight: float | None = None,
510
+ z_loss_coefficient: float = 0.0,
511
+ logits_to_keep: int | None = None,
512
+ ) -> TinyGDNOutput:
513
+ if input_ids.ndim != 2:
514
+ raise ValueError("input_ids must have shape [batch, sequence]")
515
+ if input_ids.shape[1] > self.config.max_position_embeddings:
516
+ raise ValueError("Input exceeds max_position_embeddings")
517
+ if labels is not None and labels.shape != input_ids.shape:
518
+ raise ValueError("labels must have the same shape as input_ids")
519
+ if z_loss_coefficient < 0.0:
520
+ raise ValueError("z_loss_coefficient cannot be negative")
521
+ if logits_to_keep is not None and logits_to_keep <= 0:
522
+ raise ValueError("logits_to_keep must be positive")
523
+ if use_cache and labels is not None:
524
+ raise ValueError("use_cache is not supported with labels")
525
+ effective_mtp_weight = (
526
+ self.config.mtp_loss_weight
527
+ if mtp_loss_weight is None
528
+ else mtp_loss_weight
529
+ )
530
+ if not 0.0 <= effective_mtp_weight <= 1.0:
531
+ raise ValueError("mtp_loss_weight must be between zero and one")
532
+
533
+ if use_cache and past_key_values is None:
534
+ try:
535
+ from fla.models.utils import Cache as FlaCache
536
+ except ImportError as import_error:
537
+ raise ImportError(
538
+ "Cached decode requires flash-linear-attention Cache"
539
+ ) from import_error
540
+ past_key_values = {
541
+ "fla": FlaCache(),
542
+ "gqa": [None] * len(self.layers),
543
+ }
544
+ elif past_key_values is not None and not isinstance(past_key_values, dict):
545
+ raise TypeError("past_key_values must be a TinyGDN cache dict or None")
546
+
547
+ fla_cache = None if past_key_values is None else past_key_values["fla"]
548
+ gqa_cache = None if past_key_values is None else past_key_values["gqa"]
549
+
550
+ hidden_states = self.embed_tokens(input_ids)
551
+ shared_layer_indices = set(self.config.shared_layer_indices)
552
+ for layer_index, layer in enumerate(self.layers):
553
+ layer_gqa = None if gqa_cache is None else gqa_cache[layer_index]
554
+ hidden_states, present = self._run_layer(
555
+ layer,
556
+ hidden_states,
557
+ attention_mask,
558
+ past_key_values=fla_cache,
559
+ past_key_value=layer_gqa,
560
+ use_cache=use_cache,
561
+ )
562
+ if use_cache and layer.layer_type == "full_attention" and gqa_cache is not None:
563
+ gqa_cache[layer_index] = present
564
+ if layer_index in shared_layer_indices:
565
+ layer_gqa = None if gqa_cache is None else gqa_cache[layer_index]
566
+ hidden_states, present = self._run_layer(
567
+ layer,
568
+ hidden_states,
569
+ attention_mask,
570
+ past_key_values=fla_cache,
571
+ past_key_value=layer_gqa,
572
+ use_cache=use_cache,
573
+ )
574
+ if use_cache and layer.layer_type == "full_attention" and gqa_cache is not None:
575
+ gqa_cache[layer_index] = present
576
+ hidden_states = self.final_norm(hidden_states)
577
+
578
+ main_loss = None
579
+ mtp_loss = None
580
+ z_loss = None
581
+ total_loss = None
582
+ if labels is not None:
583
+ main_target_offset = 0 if labels_are_shifted else 1
584
+ main_loss, z_loss = self._causal_loss(
585
+ hidden_states,
586
+ labels,
587
+ target_offset=main_target_offset,
588
+ compute_z_loss=z_loss_coefficient > 0.0,
589
+ )
590
+ if self.mtp_adapters and include_mtp_loss:
591
+ auxiliary_losses = [
592
+ self._causal_loss(
593
+ hidden_states,
594
+ labels,
595
+ target_offset=(
596
+ head_index + 1
597
+ if labels_are_shifted
598
+ else head_index + 2
599
+ ),
600
+ adapter=adapter,
601
+ )[0]
602
+ for head_index, adapter in enumerate(self.mtp_adapters)
603
+ ]
604
+ mtp_loss = torch.stack(auxiliary_losses).mean()
605
+ total_loss = main_loss + effective_mtp_weight * mtp_loss
606
+ else:
607
+ total_loss = main_loss
608
+ if z_loss is not None:
609
+ total_loss = total_loss + z_loss_coefficient * z_loss
610
+
611
+ output_states = (
612
+ hidden_states
613
+ if logits_to_keep is None
614
+ else hidden_states[:, -logits_to_keep:, :]
615
+ )
616
+ logits = self.project_to_vocabulary(output_states) if return_logits else None
617
+ return TinyGDNOutput(
618
+ loss=total_loss,
619
+ logits=logits,
620
+ main_loss=main_loss,
621
+ mtp_loss=mtp_loss,
622
+ z_loss=z_loss,
623
+ hidden_states=hidden_states if return_hidden_states else None,
624
+ past_key_values=past_key_values if use_cache else None,
625
+ )
626
+
627
+ def parameter_report(self) -> dict[str, int]:
628
+ total = sum(parameter.numel() for parameter in self.parameters())
629
+ mtp = sum(parameter.numel() for parameter in self.mtp_adapters.parameters())
630
+ embeddings = self.embed_tokens.weight.numel()
631
+ return {
632
+ "deployable_core": total - mtp,
633
+ "training_total": total,
634
+ "embedding": embeddings,
635
+ "mtp_auxiliary": mtp,
636
+ "non_embedding_core": total - mtp - embeddings,
637
+ }
638
+
639
+ def save_checkpoint(self, output_dir: Path) -> None:
640
+ output_dir.mkdir(parents=True, exist_ok=True)
641
+ self.config.save_json(output_dir / "config.json")
642
+ save_model(self, output_dir / "model.safetensors")
643
+
644
+ @classmethod
645
+ def from_checkpoint(
646
+ cls,
647
+ checkpoint_dir: Path,
648
+ *,
649
+ device: str | torch.device = "cpu",
650
+ dtype: torch.dtype | None = None,
651
+ ) -> TinyGDNForCausalLM:
652
+ config = TinyGDNConfig.from_json(checkpoint_dir / "config.json")
653
+ model = cls(config).to(device=device, dtype=dtype)
654
+ load_model(model, checkpoint_dir / "model.safetensors", device=str(device))
655
+ return model
656
+
657
+ def extra_repr(self) -> str:
658
+ report = self.parameter_report()
659
+ return (
660
+ f"core_parameters={report['deployable_core']:,}, "
661
+ f"training_parameters={report['training_total']:,}"
662
+ )
663
+
664
+ def get_architecture_metadata(self) -> dict[str, Any]:
665
+ return {
666
+ "architecture": self.config.architecture,
667
+ "layer_types": list(self.config.layer_types),
668
+ "effective_num_layers": self.config.effective_num_layers,
669
+ "shared_layer_indices": list(self.config.shared_layer_indices),
670
+ "parameter_report": self.parameter_report(),
671
+ "features": [
672
+ "32-layer deep-thin parameter allocation",
673
+ "Gated DeltaNet-2 recurrent memory",
674
+ "3:1 recurrent-to-full-attention hybrid",
675
+ "gated grouped-query attention",
676
+ "QK normalization",
677
+ "partial rotary embeddings",
678
+ "zero-centered RMSNorm",
679
+ "SwiGLU",
680
+ "tied input-output embeddings",
681
+ "optional multi-token prediction auxiliaries",
682
+ ],
683
+ }
tiny_gdn/smoltalk_chat.py ADDED
@@ -0,0 +1,545 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SmolTalk2 / SmolLM3 chat-template contract for Tercet-R.
2
+
3
+ The SFT subset stores the toggle and tools in `chat_template_kwargs`:
4
+
5
+ - `enable_thinking`: True → assistant turns receive a zero-loss
6
+ `<|think|>\\n` control prefix; assistant targets may emit `<think>` naturally
7
+ - `enable_thinking`: False → assistant turns receive a zero-loss
8
+ `<|no_think|>\\n` control prefix
9
+ - `custom_instructions`: the system prompt, used verbatim. Only the SmolLM3
10
+ template directives `/think`, `/no_think`, and `/system_override` are
11
+ removed because they are control markers, not prompt text
12
+ - `xml_tools` / `python_tools`: appended to the system prompt as a Hermes
13
+ `<tools>` block only when tools are present and the prompt does not already
14
+ carry one
15
+
16
+ There is no default identity, metadata header, or wrapper: a conversation
17
+ without a system prompt and without tools has no system turn at all.
18
+
19
+ Tool results (`tool` / `function` roles, or text still wrapped in Nemotron
20
+ `<tool_response>` XML) are rendered as a user turn where every result is
21
+ prefixed with the `<|tool_response|>` special token.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from dataclasses import dataclass, replace
28
+ from typing import Any, Literal
29
+
30
+ from tiny_gdn.tools import (
31
+ json_ready,
32
+ TOOL_RESPONSE_CLOSE,
33
+ TOOL_RESPONSE_OPEN,
34
+ TOOLS_CLOSE,
35
+ TOOLS_OPEN,
36
+ assistant_message_content,
37
+ coerce_tools,
38
+ message_text,
39
+ parse_arguments_payload,
40
+ strip_leaked_chatml,
41
+ )
42
+
43
+
44
+ ReasoningMode = Literal["/think", "/no_think"]
45
+
46
+ NO_THINK_TOKEN = "<|no_think|>"
47
+ NO_THINK_PREFIX = f"{NO_THINK_TOKEN}\n"
48
+ THINK_CONTROL_TOKEN = "<|think|>"
49
+ THINK_CONTROL_PREFIX = f"{THINK_CONTROL_TOKEN}\n"
50
+ TOOL_RESPONSE_TOKEN = "<|tool_response|>"
51
+ TOOL_RESPONSE_PREFIX = f"{TOOL_RESPONSE_TOKEN}\n"
52
+ THINK_OPEN = "<think>"
53
+ THINK_CLOSE = "</think>"
54
+ TOOL_CALL_OPEN = "<tool_call>"
55
+ TOOL_CALL_CLOSE = "</tool_call>"
56
+ SYSTEM_OVERRIDE_MARK = "/system_override"
57
+ THINK_MARK = "/think"
58
+ NO_THINK_MARK = "/no_think"
59
+
60
+ # SmolTalk2 SFT used this markdown web_search schema. Tercet-R stage-3
61
+ # packs Nemotron Agentic-v2 search instead (`web-search` + Tavily JSON);
62
+ # see tiny_gdn.web_search.NEMOTRON_WEB_SEARCH_TOOL.
63
+ SMOLTALK_WEB_SEARCH_TOOL = {
64
+ "type": "function",
65
+ "function": {
66
+ "name": "web_search",
67
+ "description": (
68
+ "Performs a web search for a query and returns a string of the "
69
+ "top search results formatted as markdown with titles, links, "
70
+ "and descriptions."
71
+ ),
72
+ "parameters": {
73
+ "type": "object",
74
+ "properties": {
75
+ "query": {
76
+ "type": "string",
77
+ "description": "The search query to perform.",
78
+ }
79
+ },
80
+ "required": ["query"],
81
+ },
82
+ },
83
+ }
84
+ XML_TOOLS_PREAMBLE = (
85
+ "You may call one or more functions to assist with the user query.\n"
86
+ "You are provided with function signatures within <tools></tools> XML "
87
+ "tags:\n<tools>\n"
88
+ )
89
+ XML_TOOLS_EPILOGUE = (
90
+ "</tools>\n\n"
91
+ "For each function call, return a json object with function name and "
92
+ "arguments within <tool_call></tool_call> XML tags:\n"
93
+ "<tool_call>\n"
94
+ '{"name": <function-name>, "arguments": <args-json-object>}\n'
95
+ "</tool_call>"
96
+ )
97
+ PYTHON_TOOLS_PREAMBLE = (
98
+ "When you send a message containing Python code between '<code>' and "
99
+ "'</code>' tags, it will be executed in a stateful Jupyter notebook "
100
+ "environment, and you will then be given the output to continued "
101
+ "reasoning in an agentic loop.\n\n"
102
+ "You can use the following tools in your python code like regular "
103
+ "functions:\n<tools>\n"
104
+ )
105
+ PYTHON_TOOLS_EPILOGUE = (
106
+ "</tools>\n\n"
107
+ "The state persists between code executions: so variables that you "
108
+ "define in one step are still available thereafter."
109
+ )
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class ChatTemplateKwargs:
114
+ custom_instructions: str = ""
115
+ enable_thinking: bool = True
116
+ xml_tools: tuple[str, ...] = ()
117
+ python_tools: tuple[str, ...] = ()
118
+
119
+
120
+ def coerce_tool_strings(raw_tools: Any) -> tuple[str, ...]:
121
+ if raw_tools is None:
122
+ return ()
123
+ if isinstance(raw_tools, str):
124
+ text = raw_tools.strip()
125
+ return (text,) if text else ()
126
+ if isinstance(raw_tools, dict):
127
+ raw_tools = [raw_tools]
128
+ if not isinstance(raw_tools, list):
129
+ return ()
130
+ rendered: list[str] = []
131
+ for item in raw_tools:
132
+ if isinstance(item, str):
133
+ text = item.strip()
134
+ if text:
135
+ rendered.append(text)
136
+ continue
137
+ if isinstance(item, dict):
138
+ rendered.append(json.dumps(item, ensure_ascii=False))
139
+ return tuple(rendered)
140
+
141
+
142
+ def parse_chat_template_kwargs(raw: Any) -> ChatTemplateKwargs:
143
+ if raw is None:
144
+ return ChatTemplateKwargs()
145
+ if isinstance(raw, str):
146
+ text = raw.strip()
147
+ if not text:
148
+ return ChatTemplateKwargs()
149
+ raw = json.loads(text)
150
+ if not isinstance(raw, dict):
151
+ return ChatTemplateKwargs()
152
+ enable = raw.get("enable_thinking")
153
+ if enable is None:
154
+ enable_thinking = True
155
+ elif isinstance(enable, bool):
156
+ enable_thinking = enable
157
+ else:
158
+ enable_thinking = str(enable).strip().lower() in {"1", "true", "yes", "on"}
159
+ custom = raw.get("custom_instructions")
160
+ return ChatTemplateKwargs(
161
+ custom_instructions=custom if isinstance(custom, str) else "",
162
+ enable_thinking=enable_thinking,
163
+ xml_tools=coerce_tool_strings(raw.get("xml_tools")),
164
+ python_tools=coerce_tool_strings(raw.get("python_tools")),
165
+ )
166
+
167
+
168
+ def resolve_reasoning_mode(
169
+ enable_thinking: bool,
170
+ custom_instructions: str,
171
+ ) -> ReasoningMode:
172
+ if NO_THINK_MARK in custom_instructions:
173
+ return "/no_think"
174
+ if THINK_MARK in custom_instructions:
175
+ return "/think"
176
+ return "/think" if enable_thinking else "/no_think"
177
+
178
+
179
+ def strip_template_markers(custom_instructions: str) -> str:
180
+ return (
181
+ custom_instructions.replace(NO_THINK_MARK, "")
182
+ .replace(THINK_MARK, "")
183
+ .replace(SYSTEM_OVERRIDE_MARK, "")
184
+ .strip()
185
+ )
186
+
187
+
188
+ def strip_tools_wrapper(item: str) -> str:
189
+ """Drop a `<tools>` / `</tools>` wrapper some sources bake into each entry."""
190
+
191
+ text = item.strip()
192
+ if text.startswith(TOOLS_OPEN):
193
+ text = text[len(TOOLS_OPEN) :]
194
+ if text.endswith(TOOLS_CLOSE):
195
+ text = text[: -len(TOOLS_CLOSE)]
196
+ return text.strip()
197
+
198
+
199
+ def render_xml_tools_block(xml_tools: tuple[str, ...]) -> str:
200
+ entries = [strip_tools_wrapper(item) for item in xml_tools]
201
+ entries = [item for item in entries if item]
202
+ if not entries:
203
+ return ""
204
+ body = "".join(f"{item}\n" for item in entries)
205
+ return f"{XML_TOOLS_PREAMBLE}{body}{XML_TOOLS_EPILOGUE}"
206
+
207
+
208
+ def render_python_tools_block(python_tools: tuple[str, ...]) -> str:
209
+ entries = [strip_tools_wrapper(item) for item in python_tools]
210
+ entries = [item for item in entries if item]
211
+ if not entries:
212
+ return ""
213
+ body = "".join(f"{item}\n" for item in entries)
214
+ return f"{PYTHON_TOOLS_PREAMBLE}{body}{PYTHON_TOOLS_EPILOGUE}"
215
+
216
+
217
+ def unwrap_tool_result(content: str) -> str:
218
+ """Return the bare tool output, without ChatML leaks or `<tool_response>` XML."""
219
+
220
+ text = strip_leaked_chatml(content).strip()
221
+ if text.startswith(TOOL_RESPONSE_TOKEN):
222
+ text = text[len(TOOL_RESPONSE_TOKEN) :].strip()
223
+ if TOOL_RESPONSE_OPEN in text:
224
+ chunks: list[str] = []
225
+ cursor = 0
226
+ while True:
227
+ start = text.find(TOOL_RESPONSE_OPEN, cursor)
228
+ if start < 0:
229
+ tail = text[cursor:].strip()
230
+ if tail:
231
+ chunks.append(tail)
232
+ break
233
+ head = text[cursor:start].strip()
234
+ if head:
235
+ chunks.append(head)
236
+ start += len(TOOL_RESPONSE_OPEN)
237
+ end = text.find(TOOL_RESPONSE_CLOSE, start)
238
+ if end < 0:
239
+ inner = text[start:].strip()
240
+ cursor = len(text)
241
+ else:
242
+ inner = text[start:end].strip()
243
+ cursor = end + len(TOOL_RESPONSE_CLOSE)
244
+ if inner:
245
+ chunks.append(inner)
246
+ if cursor >= len(text):
247
+ break
248
+ text = "\n".join(chunks)
249
+ return text
250
+
251
+
252
+ def wrap_smoltalk_tool_result(content: str) -> str:
253
+ text = unwrap_tool_result(content)
254
+ if not text:
255
+ return ""
256
+ return f"{TOOL_RESPONSE_PREFIX}{text}"
257
+
258
+
259
+ def openai_tools_as_xml_strings(raw_tools: Any) -> tuple[str, ...]:
260
+ if raw_tools is None:
261
+ return ()
262
+ rendered: list[str] = []
263
+ for tool in coerce_tools(raw_tools):
264
+ rendered.append(
265
+ json.dumps(
266
+ json_ready({"type": "function", "function": tool}),
267
+ ensure_ascii=False,
268
+ default=str,
269
+ )
270
+ )
271
+ return tuple(rendered)
272
+
273
+
274
+ def format_smoltalk_tool_call(name: str, arguments: dict[str, Any]) -> str:
275
+ payload = {"name": name, "arguments": json_ready(arguments)}
276
+ return (
277
+ f"{TOOL_CALL_OPEN}\n"
278
+ f"{json.dumps(payload, ensure_ascii=False, default=str)}\n"
279
+ f"{TOOL_CALL_CLOSE}"
280
+ )
281
+
282
+
283
+ def format_smoltalk_tool_calls(raw_tool_calls: Any) -> str:
284
+ if not raw_tool_calls:
285
+ return ""
286
+ if not isinstance(raw_tool_calls, list):
287
+ raise ValueError("tool_calls must be a list")
288
+ chunks: list[str] = []
289
+ for raw_call in raw_tool_calls:
290
+ if not isinstance(raw_call, dict):
291
+ raise ValueError("Each tool_call must be an object")
292
+ payload = (
293
+ raw_call.get("function")
294
+ if isinstance(raw_call.get("function"), dict)
295
+ else raw_call
296
+ )
297
+ if not isinstance(payload, dict):
298
+ raise ValueError("tool_call is missing a function object")
299
+ name = payload.get("name")
300
+ if not isinstance(name, str) or not name.strip():
301
+ raise ValueError("tool_call is missing a function name")
302
+ arguments = parse_arguments_payload(payload.get("arguments"))
303
+ chunks.append(format_smoltalk_tool_call(name.strip(), arguments))
304
+ return "\n".join(chunks)
305
+
306
+
307
+ def assistant_smoltalk_content(message: dict[str, Any]) -> str:
308
+ content = assistant_message_content(
309
+ {**message, "tool_calls": None}
310
+ )
311
+ if TOOL_CALL_OPEN in content:
312
+ return content
313
+ tool_xml = format_smoltalk_tool_calls(message.get("tool_calls"))
314
+ if not tool_xml:
315
+ return content
316
+ if content.strip():
317
+ return f"{content.rstrip()}\n{tool_xml}"
318
+ return tool_xml
319
+
320
+
321
+ def render_system_prompt(kwargs: ChatTemplateKwargs) -> str:
322
+ """Render the system turn verbatim; empty string means no system turn."""
323
+
324
+ custom = strip_leaked_chatml(strip_template_markers(kwargs.custom_instructions))
325
+ tool_blocks: list[str] = []
326
+ if TOOLS_OPEN not in custom:
327
+ xml_block = render_xml_tools_block(kwargs.xml_tools)
328
+ if xml_block:
329
+ tool_blocks.append(xml_block)
330
+ python_block = render_python_tools_block(kwargs.python_tools)
331
+ if python_block:
332
+ tool_blocks.append(python_block)
333
+ sections = [custom] if custom else []
334
+ sections.extend(tool_blocks)
335
+ return "\n\n".join(sections).strip()
336
+
337
+
338
+ def merge_kwargs_with_system_message(
339
+ messages: list[dict[str, Any]],
340
+ kwargs: ChatTemplateKwargs,
341
+ ) -> tuple[list[dict[str, Any]], ChatTemplateKwargs]:
342
+ if not messages:
343
+ return messages, kwargs
344
+ first = messages[0]
345
+ if first.get("role") != "system":
346
+ return messages, kwargs
347
+ system_text = message_text(first.get("content"))
348
+ if system_text.strip():
349
+ merged = (
350
+ system_text
351
+ if not kwargs.custom_instructions.strip()
352
+ else f"{kwargs.custom_instructions.rstrip()}\n\n{system_text.strip()}"
353
+ )
354
+ kwargs = replace(kwargs, custom_instructions=merged)
355
+ return messages[1:], kwargs
356
+
357
+
358
+ def kwargs_from_inference_request(
359
+ *,
360
+ enable_thinking: bool,
361
+ custom_instructions: str = "",
362
+ xml_tools: Any = None,
363
+ python_tools: Any = None,
364
+ tools: Any = None,
365
+ ) -> ChatTemplateKwargs:
366
+ xml = coerce_tool_strings(xml_tools)
367
+ if not xml:
368
+ xml = openai_tools_as_xml_strings(tools)
369
+ return ChatTemplateKwargs(
370
+ custom_instructions=custom_instructions,
371
+ enable_thinking=enable_thinking,
372
+ xml_tools=xml,
373
+ python_tools=coerce_tool_strings(python_tools),
374
+ )
375
+
376
+
377
+ def _normalize_role(raw_role: Any, index: int) -> str:
378
+ if not isinstance(raw_role, str):
379
+ raise ValueError(f"Unsupported chat role at index {index}: {raw_role!r}")
380
+ role = raw_role.strip().lower()
381
+ if role == "human":
382
+ return "user"
383
+ if role == "gpt":
384
+ return "assistant"
385
+ if role == "function":
386
+ return "tool"
387
+ if role not in {"system", "user", "assistant", "tool"}:
388
+ raise ValueError(f"Unsupported chat role at index {index}: {raw_role!r}")
389
+ return role
390
+
391
+
392
+ def materialize_smoltalk_messages(
393
+ raw_messages: list[dict[str, Any]],
394
+ kwargs: ChatTemplateKwargs,
395
+ ) -> list[dict[str, str]]:
396
+ if not raw_messages:
397
+ raise ValueError("Chat history cannot be empty")
398
+ remaining, resolved = merge_kwargs_with_system_message(raw_messages, kwargs)
399
+ for index, raw_message in enumerate(remaining):
400
+ if not isinstance(raw_message, dict):
401
+ raise ValueError(f"Unsupported chat message at index {index}")
402
+ if _normalize_role(raw_message.get("role"), index) != "system":
403
+ continue
404
+ extra = strip_leaked_chatml(message_text(raw_message.get("content")))
405
+ if extra:
406
+ resolved = replace(
407
+ resolved,
408
+ custom_instructions=(
409
+ f"{resolved.custom_instructions.rstrip()}\n\n{extra}".strip()
410
+ ),
411
+ )
412
+ mode = resolve_reasoning_mode(
413
+ resolved.enable_thinking,
414
+ resolved.custom_instructions,
415
+ )
416
+ masked_prefix = THINK_CONTROL_PREFIX if mode == "/think" else NO_THINK_PREFIX
417
+ prepared: list[dict[str, str]] = []
418
+ system_prompt = render_system_prompt(resolved)
419
+ if system_prompt:
420
+ prepared.append({"role": "system", "content": system_prompt})
421
+ pending_tool_results: list[str] = []
422
+ for index, raw_message in enumerate(remaining):
423
+ role = _normalize_role(raw_message.get("role"), index)
424
+ if role == "system":
425
+ continue
426
+ if role == "assistant":
427
+ content = assistant_smoltalk_content(raw_message)
428
+ else:
429
+ content = message_text(raw_message.get("content"))
430
+ if role == "tool":
431
+ content = wrap_smoltalk_tool_result(content)
432
+ if not content:
433
+ raise ValueError(f"Chat content at index {index} must be non-empty")
434
+ pending_tool_results.append(content)
435
+ continue
436
+ if pending_tool_results:
437
+ prepared.append(
438
+ {"role": "user", "content": "\n".join(pending_tool_results)}
439
+ )
440
+ pending_tool_results = []
441
+ if not content.strip():
442
+ raise ValueError(f"Chat content at index {index} must be non-empty")
443
+ prepared_message = {"role": role, "content": content}
444
+ if role == "assistant":
445
+ prepared_message["masked_prefix"] = masked_prefix
446
+ prepared.append(prepared_message)
447
+ if pending_tool_results:
448
+ prepared.append({"role": "user", "content": "\n".join(pending_tool_results)})
449
+ if not any(message["role"] != "system" for message in prepared):
450
+ raise ValueError("Chat history cannot be empty")
451
+ return prepared
452
+
453
+
454
+ def format_think_block(reasoning: str, content: str) -> str:
455
+ """SmolTalk2 layout: `<think>\\n…\\n</think>\\n\\nanswer`."""
456
+
457
+ body = reasoning.strip()
458
+ if content:
459
+ return f"{THINK_OPEN}\n{body}\n{THINK_CLOSE}\n\n{content}"
460
+ return f"{THINK_OPEN}\n{body}\n{THINK_CLOSE}\n\n"
461
+
462
+
463
+ def render_assistant_turn(
464
+ content: str,
465
+ *,
466
+ reasoning: str | None,
467
+ tool_calls: list[dict[str, Any]] | None,
468
+ ) -> dict[str, str]:
469
+ """Render one assistant turn with a per-turn thinking control prefix.
470
+
471
+ A turn with reasoning gets `<|think|>` and a `<think>` block; a turn
472
+ without reasoning gets `<|no_think|>`. Tool calls become SmolTalk JSON
473
+ `<tool_call>` blocks unless the content already inlines them.
474
+ """
475
+
476
+ text = content.strip()
477
+ if tool_calls and TOOL_CALL_OPEN not in text:
478
+ calls = "\n".join(
479
+ format_smoltalk_tool_call(call["name"], call["arguments"]) for call in tool_calls
480
+ )
481
+ text = f"{text}\n{calls}" if text else calls
482
+ if reasoning and reasoning.strip():
483
+ return {
484
+ "role": "assistant",
485
+ "content": format_think_block(reasoning, text),
486
+ "masked_prefix": THINK_CONTROL_PREFIX,
487
+ }
488
+ return {"role": "assistant", "content": text, "masked_prefix": NO_THINK_PREFIX}
489
+
490
+
491
+ def materialize_canonical_conversation(
492
+ *,
493
+ system: str | None,
494
+ tools: Any,
495
+ messages: list[dict[str, Any]],
496
+ ) -> list[dict[str, str]]:
497
+ """Render a stage-3 canonical conversation into ChatML-ready turns.
498
+
499
+ `messages` entries are `{"role", "content", "reasoning"?, "tool_calls"?}`
500
+ with roles user / assistant / tool. The system prompt is used verbatim;
501
+ tools are appended only when the prompt lacks a `<tools>` block.
502
+ """
503
+
504
+ kwargs = ChatTemplateKwargs(
505
+ custom_instructions=system or "",
506
+ xml_tools=openai_tools_as_xml_strings(tools) if tools else (),
507
+ )
508
+ prepared: list[dict[str, str]] = []
509
+ system_prompt = render_system_prompt(kwargs)
510
+ if system_prompt:
511
+ prepared.append({"role": "system", "content": system_prompt})
512
+ pending_tool_results: list[str] = []
513
+ for index, message in enumerate(messages):
514
+ role = message["role"]
515
+ if role == "tool":
516
+ wrapped = wrap_smoltalk_tool_result(message_text(message.get("content")))
517
+ if not wrapped:
518
+ raise ValueError(f"Tool result at index {index} is empty")
519
+ pending_tool_results.append(wrapped)
520
+ continue
521
+ if pending_tool_results:
522
+ prepared.append({"role": "user", "content": "\n".join(pending_tool_results)})
523
+ pending_tool_results = []
524
+ if role == "user":
525
+ content = message_text(message.get("content"))
526
+ if not content.strip():
527
+ raise ValueError(f"User turn at index {index} is empty")
528
+ prepared.append({"role": "user", "content": content})
529
+ continue
530
+ if role == "assistant":
531
+ rendered = render_assistant_turn(
532
+ message_text(message.get("content")),
533
+ reasoning=message.get("reasoning"),
534
+ tool_calls=message.get("tool_calls"),
535
+ )
536
+ if not rendered["content"].strip():
537
+ raise ValueError(f"Assistant turn at index {index} is empty")
538
+ prepared.append(rendered)
539
+ continue
540
+ raise ValueError(f"Unsupported role at index {index}: {role!r}")
541
+ if pending_tool_results:
542
+ raise ValueError("Conversation ends with an unanswered tool result")
543
+ if not prepared or prepared[-1]["role"] != "assistant":
544
+ raise ValueError("Conversation must end with an assistant turn")
545
+ return prepared
tiny_gdn/tools.py ADDED
@@ -0,0 +1,579 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Nemotron Cascade-2 tool protocol (XML in ChatML content).
2
+
3
+ Tercet-R is trained on `nvidia/Nemotron-Cascade-2-SFT-Data`, which inlines
4
+ available tools, calls, and results as `<tools>` / `<tool_call>` /
5
+ `<tool_response>` in message text. That is not OpenAI `tool_calls` JSON and
6
+ not this tokenizer's unused `<|tool_call|>` / `<|tool_response|>` specials.
7
+
8
+ This module matches NVIDIA's Cascade-2 chat template:
9
+ https://huggingface.co/nvidia/Nemotron-Cascade-2-30B-A3B/blob/main/chat_template.jinja
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ import sys
17
+ from dataclasses import dataclass
18
+ from typing import Any
19
+
20
+
21
+ TOOL_CALL_OPEN = "<tool_call>"
22
+ TOOL_CALL_CLOSE = "</tool_call>"
23
+ TOOL_RESPONSE_OPEN = "<tool_response>"
24
+ TOOL_RESPONSE_CLOSE = "</tool_response>"
25
+ TOOLS_OPEN = "<tools>"
26
+ TOOLS_CLOSE = "</tools>"
27
+ THINK_OPEN = "<think>"
28
+ THINK_CLOSE = "</think>"
29
+
30
+ CHATML_START_RE = re.compile(r"^<\|im_start\|>[A-Za-z]+\n")
31
+ CHATML_END_RE = re.compile(r"\n?<\|im_end\|>\s*$")
32
+ TOOL_CALL_BLOCK_RE = re.compile(
33
+ rf"{re.escape(TOOL_CALL_OPEN)}(.*?){re.escape(TOOL_CALL_CLOSE)}",
34
+ re.DOTALL,
35
+ )
36
+ FUNCTION_BLOCK_RE = re.compile(
37
+ r"<function=([^>\s]+)>(.*?)</function>",
38
+ re.DOTALL,
39
+ )
40
+ PARAMETER_BLOCK_RE = re.compile(
41
+ r"<parameter=([^>\s]+)>\n?(.*?)\n?</parameter>",
42
+ re.DOTALL,
43
+ )
44
+ FUNCTION_NAME_RE = re.compile(r"<function=([^>\s]+)")
45
+
46
+ INFERENCE_ROLE_MAP = {
47
+ "system": "system",
48
+ "user": "user",
49
+ "human": "user",
50
+ "assistant": "assistant",
51
+ "gpt": "assistant",
52
+ "tool": "tool",
53
+ "function": "tool",
54
+ }
55
+
56
+ TOOLS_PREAMBLE = "# Tools\n\nYou have access to the following functions:\n\n"
57
+ TOOL_CALL_INSTRUCTIONS = (
58
+ "\n\nIf you choose to call a function ONLY reply in the following format "
59
+ "with NO suffix:\n\n"
60
+ "<tool_call>\n"
61
+ "<function=example_function_name>\n"
62
+ "<parameter=example_parameter_1>\n"
63
+ "value_1\n"
64
+ "</parameter>\n"
65
+ "<parameter=example_parameter_2>\n"
66
+ "This is the value for the second parameter\n"
67
+ "that can span\n"
68
+ "multiple lines\n"
69
+ "</parameter>\n"
70
+ "</function>\n"
71
+ "</tool_call>\n\n"
72
+ "<IMPORTANT>\n"
73
+ "Reminder:\n"
74
+ "- Function calls MUST follow the specified format: an inner "
75
+ "<function=...></function> block must be nested within "
76
+ "<tool_call></tool_call> XML tags\n"
77
+ "- Required parameters MUST be specified\n"
78
+ "- You may provide optional reasoning for your function call in natural "
79
+ "language BEFORE the function call, but NOT after\n"
80
+ "- If there is no function call available, answer the question like "
81
+ "normal with your current knowledge and do not tell the user about "
82
+ "function calls\n"
83
+ "</IMPORTANT>"
84
+ )
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class ParsedToolCall:
89
+ name: str
90
+ arguments: dict[str, Any]
91
+ raw: str
92
+
93
+
94
+ def message_text(raw_content: Any) -> str:
95
+ if raw_content is None:
96
+ return ""
97
+ if isinstance(raw_content, str):
98
+ return raw_content
99
+ if isinstance(raw_content, list):
100
+ parts: list[str] = []
101
+ for item in raw_content:
102
+ if isinstance(item, str):
103
+ parts.append(item)
104
+ continue
105
+ if not isinstance(item, dict):
106
+ continue
107
+ part_type = item.get("type")
108
+ if part_type in {None, "text", "input_text", "output_text"}:
109
+ text = item.get("text")
110
+ if isinstance(text, str):
111
+ parts.append(text)
112
+ return "".join(parts)
113
+ return str(raw_content)
114
+
115
+
116
+ def strip_leaked_chatml(content: str) -> str:
117
+ text = content.strip()
118
+ while True:
119
+ match = CHATML_START_RE.match(text)
120
+ if match is None:
121
+ break
122
+ text = text[match.end() :]
123
+ text = CHATML_END_RE.sub("", text)
124
+ return text.strip()
125
+
126
+
127
+ def has_tools_block(content: str) -> bool:
128
+ return TOOLS_OPEN in content
129
+
130
+
131
+ def wrap_tool_response(content: str) -> str:
132
+ text = strip_leaked_chatml(content)
133
+ if TOOL_RESPONSE_OPEN in text:
134
+ return text
135
+ return f"{TOOL_RESPONSE_OPEN}\n\n\n{text}\n{TOOL_RESPONSE_CLOSE}"
136
+
137
+
138
+ def _xml_value(value: Any) -> str:
139
+ if isinstance(value, dict) or (
140
+ isinstance(value, (list, tuple)) and not isinstance(value, (str, bytes))
141
+ ):
142
+ return json.dumps(value, ensure_ascii=False)
143
+ if value is True or value is False or value is None:
144
+ return str(value)
145
+ return str(value)
146
+
147
+
148
+ def _render_extra_keys(payload: dict[str, Any], handled: set[str]) -> str:
149
+ chunks: list[str] = []
150
+ for key, value in payload.items():
151
+ if key in handled:
152
+ continue
153
+ chunks.append(f"\n<{key}>{_xml_value(value)}</{key}>")
154
+ return "".join(chunks)
155
+
156
+
157
+ def _unwrap_tool(raw_tool: Any) -> dict[str, Any]:
158
+ if not isinstance(raw_tool, dict):
159
+ raise ValueError("Each tool must be an object")
160
+ if isinstance(raw_tool.get("function"), dict):
161
+ tool = dict(raw_tool["function"])
162
+ else:
163
+ tool = dict(raw_tool)
164
+ name = tool.get("name")
165
+ if not isinstance(name, str) or not name.strip():
166
+ raise ValueError("Tool is missing a function name")
167
+ tool["name"] = name.strip()
168
+ return tool
169
+
170
+
171
+ def coerce_tools(raw_tools: Any) -> list[dict[str, Any]]:
172
+ if raw_tools is None:
173
+ return []
174
+ if isinstance(raw_tools, str):
175
+ text = raw_tools.strip()
176
+ if not text:
177
+ return []
178
+ raw_tools = loads_json(text)
179
+ if isinstance(raw_tools, dict):
180
+ raw_tools = [raw_tools]
181
+ if not isinstance(raw_tools, list):
182
+ raise ValueError("tools must be a list of function specs")
183
+ return [_unwrap_tool(item) for item in raw_tools]
184
+
185
+
186
+ def render_function_schema(tool: dict[str, Any]) -> str:
187
+ chunks = [f"\n<function>\n<name>{tool['name']}</name>"]
188
+ description = tool.get("description")
189
+ if isinstance(description, str) and description.strip():
190
+ chunks.append(f"\n<description>{description.strip()}</description>")
191
+ chunks.append("\n<parameters>")
192
+ parameters = tool.get("parameters")
193
+ properties: dict[str, Any] = {}
194
+ if isinstance(parameters, dict):
195
+ raw_properties = parameters.get("properties")
196
+ if isinstance(raw_properties, dict):
197
+ properties = raw_properties
198
+ for param_name, raw_fields in properties.items():
199
+ fields = raw_fields if isinstance(raw_fields, dict) else {}
200
+ chunks.append("\n<parameter>")
201
+ chunks.append(f"\n<name>{param_name}</name>")
202
+ if "type" in fields:
203
+ chunks.append(f"\n<type>{_xml_value(fields['type'])}</type>")
204
+ if isinstance(fields.get("description"), str) and fields["description"].strip():
205
+ chunks.append(
206
+ f"\n<description>{fields['description'].strip()}</description>"
207
+ )
208
+ if "enum" in fields:
209
+ chunks.append(f"\n<enum>{_xml_value(fields['enum'])}</enum>")
210
+ chunks.append(
211
+ _render_extra_keys(
212
+ fields,
213
+ {"name", "type", "description", "enum"},
214
+ )
215
+ )
216
+ chunks.append("\n</parameter>")
217
+ chunks.append(
218
+ _render_extra_keys(parameters, {"type", "properties", "required"})
219
+ )
220
+ if "required" in parameters:
221
+ chunks.append(f"\n<required>{_xml_value(parameters['required'])}</required>")
222
+ chunks.append("\n</parameters>")
223
+ chunks.append(
224
+ _render_extra_keys(
225
+ tool,
226
+ {"type", "name", "description", "parameters"},
227
+ )
228
+ )
229
+ chunks.append("\n</function>")
230
+ return "".join(chunks)
231
+
232
+
233
+ def render_available_tools(raw_tools: Any) -> str:
234
+ tools = coerce_tools(raw_tools)
235
+ if not tools:
236
+ return ""
237
+ body = "".join(render_function_schema(tool) for tool in tools)
238
+ return (
239
+ f"{TOOLS_PREAMBLE}{TOOLS_OPEN}{body}\n{TOOLS_CLOSE}"
240
+ f"{TOOL_CALL_INSTRUCTIONS}"
241
+ )
242
+
243
+
244
+ def inject_available_tools(system_content: str, raw_tools: Any) -> str:
245
+ block = render_available_tools(raw_tools)
246
+ if not block:
247
+ return system_content
248
+ if has_tools_block(system_content):
249
+ return system_content
250
+ if not system_content.strip():
251
+ return block
252
+ return f"{system_content.rstrip()}\n\n{block}"
253
+
254
+
255
+ def parse_json_int(text: str) -> int | str:
256
+ """Keep oversized JSON integers as strings.
257
+
258
+ Python 3.12 refuses to convert integers longer than
259
+ ``sys.get_int_max_str_digits()`` (default 4300). Tool-call payloads
260
+ sometimes embed hashes or blobs as bare JSON numbers; those must not
261
+ abort packing.
262
+ """
263
+
264
+ digits = text.lstrip("+-")
265
+ limit = sys.get_int_max_str_digits()
266
+ if limit and len(digits) > limit:
267
+ return text
268
+ return int(text)
269
+
270
+
271
+ def loads_json(text: str) -> Any:
272
+ return json.loads(text, parse_int=parse_json_int)
273
+
274
+
275
+ # Models sometimes close a JSON string with `".}` instead of `"}`.
276
+ _TRAILING_STRING_PERIOD_RE = re.compile(r'"\s*\.(?=\s*[}\],])')
277
+ _TRAILING_COMMA_RE = re.compile(r",\s*(?=[}\]])")
278
+ _JSON_NAME_RE = re.compile(r'"name"\s*:\s*"([^"]+)"')
279
+ _JSON_QUERY_RE = re.compile(r'"query"\s*:\s*"([^"]*)"')
280
+
281
+
282
+ def repair_jsonish(text: str) -> str:
283
+ repaired = _TRAILING_STRING_PERIOD_RE.sub('"', text.strip())
284
+ return _TRAILING_COMMA_RE.sub("", repaired)
285
+
286
+
287
+ def loads_jsonish(text: str) -> Any:
288
+ candidates = (text.strip(), repair_jsonish(text), text.strip().replace("'", '"'))
289
+ seen: set[str] = set()
290
+ last_error: Exception | None = None
291
+ for candidate in candidates:
292
+ if not candidate or candidate in seen:
293
+ continue
294
+ seen.add(candidate)
295
+ try:
296
+ return loads_json(candidate)
297
+ except (json.JSONDecodeError, ValueError, RecursionError) as error:
298
+ last_error = error
299
+ if last_error is not None:
300
+ raise last_error
301
+ raise json.JSONDecodeError("Empty JSON", text, 0)
302
+
303
+
304
+ def extract_json_tool_fields(text: str) -> tuple[str, dict[str, Any]] | None:
305
+ name_match = _JSON_NAME_RE.search(text)
306
+ if name_match is None:
307
+ return None
308
+ arguments: dict[str, Any] = {}
309
+ query_match = _JSON_QUERY_RE.search(text)
310
+ if query_match is not None:
311
+ arguments["query"] = query_match.group(1)
312
+ return name_match.group(1).strip(), arguments
313
+
314
+
315
+ def _json_ready_int(value: int) -> int | str:
316
+ try:
317
+ json.dumps(value)
318
+ except ValueError:
319
+ previous = sys.get_int_max_str_digits()
320
+ sys.set_int_max_str_digits(0)
321
+ try:
322
+ return str(value)
323
+ finally:
324
+ sys.set_int_max_str_digits(previous)
325
+ return value
326
+
327
+
328
+ def json_ready(value: Any) -> Any:
329
+ """Coerce Python literals into JSON-serialisable values.
330
+
331
+ Some SFT sources store tool arguments as Python literals. `ast.literal_eval`
332
+ turns `{1, 2}` into a `set` and `...` into `Ellipsis`, which later
333
+ `json.dumps` calls reject. Oversized ints are stored as decimal strings.
334
+ """
335
+
336
+ if value is None or isinstance(value, (bool, float, str)):
337
+ return value
338
+ if isinstance(value, int):
339
+ return _json_ready_int(value)
340
+ if value is Ellipsis:
341
+ return None
342
+ if isinstance(value, dict):
343
+ return {str(key): json_ready(item) for key, item in value.items()}
344
+ if isinstance(value, (set, frozenset)):
345
+ items = [json_ready(item) for item in value]
346
+ try:
347
+ return sorted(
348
+ items,
349
+ key=lambda item: json.dumps(item, sort_keys=True, default=str),
350
+ )
351
+ except TypeError:
352
+ return items
353
+ if isinstance(value, tuple):
354
+ return [json_ready(item) for item in value]
355
+ if isinstance(value, list):
356
+ return [json_ready(item) for item in value]
357
+ if isinstance(value, bytes):
358
+ try:
359
+ return value.decode("utf-8")
360
+ except UnicodeDecodeError:
361
+ return list(value)
362
+ try:
363
+ json.dumps(value)
364
+ return value
365
+ except (TypeError, ValueError):
366
+ return str(value)
367
+
368
+
369
+ def parse_argument_value(raw: str) -> Any:
370
+ text = raw.strip()
371
+ if not text:
372
+ return ""
373
+ try:
374
+ return loads_json(text)
375
+ except (json.JSONDecodeError, ValueError, RecursionError):
376
+ return text
377
+
378
+
379
+ def parse_arguments_payload(raw: Any) -> dict[str, Any]:
380
+ if raw is None:
381
+ return {}
382
+ ready = json_ready(raw)
383
+ if isinstance(ready, dict):
384
+ return ready
385
+ if isinstance(ready, str):
386
+ text = ready.strip()
387
+ if not text:
388
+ return {}
389
+ try:
390
+ loaded = loads_json(text)
391
+ except (json.JSONDecodeError, ValueError, RecursionError):
392
+ return {"value": ready}
393
+ loaded = json_ready(loaded)
394
+ if isinstance(loaded, dict):
395
+ return loaded
396
+ return {"value": loaded}
397
+ return {"value": ready}
398
+
399
+
400
+ def format_tool_call_xml(name: str, arguments: dict[str, Any]) -> str:
401
+ chunks = [f"{TOOL_CALL_OPEN}\n<function={name}>\n"]
402
+ for key, value in arguments.items():
403
+ chunks.append(f"<parameter={key}>\n{_xml_value(value)}\n</parameter>\n")
404
+ chunks.append(f"</function>\n{TOOL_CALL_CLOSE}\n")
405
+ return "".join(chunks)
406
+
407
+
408
+ def format_tool_calls_xml(raw_tool_calls: Any) -> str:
409
+ if not raw_tool_calls:
410
+ return ""
411
+ if not isinstance(raw_tool_calls, list):
412
+ raise ValueError("tool_calls must be a list")
413
+ chunks: list[str] = []
414
+ for raw_call in raw_tool_calls:
415
+ if not isinstance(raw_call, dict):
416
+ raise ValueError("Each tool_call must be an object")
417
+ payload = raw_call.get("function") if isinstance(raw_call.get("function"), dict) else raw_call
418
+ if not isinstance(payload, dict):
419
+ raise ValueError("tool_call is missing a function object")
420
+ name = payload.get("name")
421
+ if not isinstance(name, str) or not name.strip():
422
+ raise ValueError("tool_call is missing a function name")
423
+ arguments = parse_arguments_payload(payload.get("arguments"))
424
+ chunks.append(format_tool_call_xml(name.strip(), arguments))
425
+ return "".join(chunks)
426
+
427
+
428
+ def parse_json_tool_inner(inner: str) -> ParsedToolCall | None:
429
+ text = inner.strip()
430
+ if not text:
431
+ return None
432
+ payload: Any | None
433
+ try:
434
+ payload = loads_jsonish(text)
435
+ except (json.JSONDecodeError, ValueError, RecursionError):
436
+ payload = None
437
+ if isinstance(payload, dict):
438
+ nested = payload.get("function")
439
+ source = nested if isinstance(nested, dict) else payload
440
+ name = source.get("name")
441
+ if not isinstance(name, str) or not name.strip():
442
+ name = payload.get("name")
443
+ if isinstance(name, str) and name.strip():
444
+ arguments = parse_arguments_payload(
445
+ source.get(
446
+ "arguments",
447
+ source.get("parameters", payload.get("arguments")),
448
+ )
449
+ )
450
+ return ParsedToolCall(name=name.strip(), arguments=arguments, raw="")
451
+ extracted = extract_json_tool_fields(repair_jsonish(text))
452
+ if extracted is None:
453
+ return None
454
+ name, arguments = extracted
455
+ return ParsedToolCall(name=name, arguments=arguments, raw="")
456
+
457
+
458
+ def parse_tool_calls(text: str) -> list[ParsedToolCall]:
459
+ calls: list[ParsedToolCall] = []
460
+ for block in TOOL_CALL_BLOCK_RE.finditer(text):
461
+ inner = block.group(1)
462
+ raw = block.group(0).strip()
463
+ found_xml = False
464
+ for function in FUNCTION_BLOCK_RE.finditer(inner):
465
+ found_xml = True
466
+ name = function.group(1).strip()
467
+ arguments: dict[str, Any] = {}
468
+ for parameter in PARAMETER_BLOCK_RE.finditer(function.group(2)):
469
+ arguments[parameter.group(1).strip()] = parse_argument_value(
470
+ parameter.group(2)
471
+ )
472
+ calls.append(
473
+ ParsedToolCall(
474
+ name=name,
475
+ arguments=json_ready(arguments),
476
+ raw=raw,
477
+ )
478
+ )
479
+ if found_xml:
480
+ continue
481
+ parsed = parse_json_tool_inner(inner)
482
+ if parsed is not None:
483
+ calls.append(
484
+ ParsedToolCall(name=parsed.name, arguments=parsed.arguments, raw=raw)
485
+ )
486
+ return calls
487
+
488
+
489
+ def openai_tool_calls_from_text(text: str) -> list[dict[str, Any]]:
490
+ encoded: list[dict[str, Any]] = []
491
+ for index, call in enumerate(parse_tool_calls(text)):
492
+ encoded.append(
493
+ {
494
+ "id": f"call_{index}_{call.name}",
495
+ "type": "function",
496
+ "function": {
497
+ "name": call.name,
498
+ "arguments": json.dumps(call.arguments, ensure_ascii=False),
499
+ },
500
+ }
501
+ )
502
+ return encoded
503
+
504
+
505
+ def assistant_message_content(message: dict[str, Any]) -> str:
506
+ reasoning = message.get("reasoning_content")
507
+ content = message_text(message.get("content"))
508
+ if isinstance(reasoning, str) and reasoning.strip():
509
+ content = f"{THINK_OPEN}\n{reasoning.strip()}\n{THINK_CLOSE}\n{content}"
510
+ tool_xml = format_tool_calls_xml(message.get("tool_calls"))
511
+ if tool_xml:
512
+ if content.strip():
513
+ return f"{content.rstrip()}\n{tool_xml}"
514
+ return tool_xml
515
+ return content
516
+
517
+
518
+ def _flush_tool_group(
519
+ group: list[str],
520
+ messages: list[dict[str, str]],
521
+ ) -> None:
522
+ if not group:
523
+ return
524
+ messages.append({"role": "user", "content": "\n".join(group)})
525
+ group.clear()
526
+
527
+
528
+ def prepare_inference_messages(
529
+ raw_messages: list[dict[str, Any]],
530
+ *,
531
+ tools: Any | None = None,
532
+ ) -> list[dict[str, str]]:
533
+ if not raw_messages:
534
+ raise ValueError("Chat history cannot be empty")
535
+
536
+ prepared: list[dict[str, str]] = []
537
+ pending_tool_results: list[str] = []
538
+ for index, raw_message in enumerate(raw_messages):
539
+ if not isinstance(raw_message, dict):
540
+ raise ValueError(f"Unsupported chat message at index {index}")
541
+ raw_role = raw_message.get("role")
542
+ if not isinstance(raw_role, str):
543
+ raise ValueError(f"Unsupported chat role at index {index}: {raw_role!r}")
544
+ role = INFERENCE_ROLE_MAP.get(raw_role.strip().lower())
545
+ if role is None:
546
+ raise ValueError(f"Unsupported chat role at index {index}: {raw_role!r}")
547
+ if role == "assistant":
548
+ content = assistant_message_content(raw_message)
549
+ else:
550
+ content = message_text(raw_message.get("content"))
551
+ if role == "tool":
552
+ content = wrap_tool_response(content)
553
+ if not content.strip():
554
+ raise ValueError(f"Chat content at index {index} must be non-empty")
555
+ pending_tool_results.append(content)
556
+ continue
557
+ _flush_tool_group(pending_tool_results, prepared)
558
+ if not content.strip():
559
+ if role == "system":
560
+ continue
561
+ raise ValueError(f"Chat content at index {index} must be non-empty")
562
+ if role == "system":
563
+ content = strip_leaked_chatml(content)
564
+ prepared.append({"role": role, "content": content})
565
+ _flush_tool_group(pending_tool_results, prepared)
566
+
567
+ tools_block = render_available_tools(tools)
568
+ if tools_block:
569
+ if prepared and prepared[0]["role"] == "system":
570
+ prepared[0] = {
571
+ "role": "system",
572
+ "content": inject_available_tools(prepared[0]["content"], tools),
573
+ }
574
+ else:
575
+ prepared.insert(0, {"role": "system", "content": tools_block})
576
+
577
+ if not prepared:
578
+ raise ValueError("Chat history cannot be empty")
579
+ return prepared
tiny_gdn/web_search.py ADDED
@@ -0,0 +1,475 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Execute Nemotron Agentic-v2 `web-search` the way Tercet-R stage-3 SFT saw it.
2
+
3
+ `nvidia/Nemotron-SFT-Agentic-v2` `data/search.jsonl` (packed as
4
+ `agentic_v2_search`) declares a single tool named `web-search` whose
5
+ observations are compact Tavily Search JSON:
6
+
7
+ {"query", "follow_up_questions", "answer", "images", "results",
8
+ "response_time", "request_id"}
9
+
10
+ Each `results[]` item is `{url, title, content, score, raw_content}`
11
+ (about ten hits, with an LLM `answer` when Tavily supplies one). The chat
12
+ template then prefixes that JSON with `<|tool_response|>`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import time
20
+ from collections.abc import Mapping, Sequence
21
+ from html import unescape
22
+ from html.parser import HTMLParser
23
+ from typing import Any
24
+ from urllib.error import HTTPError, URLError
25
+ from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse
26
+ from urllib.request import Request, urlopen
27
+
28
+
29
+ NEMOTRON_WEB_SEARCH_TOOL: dict[str, Any] = {
30
+ "type": "function",
31
+ "function": {
32
+ "name": "web-search",
33
+ "description": "Search the web for a query.",
34
+ "parameters": {
35
+ "type": "object",
36
+ "properties": {
37
+ "query": {
38
+ "type": "string",
39
+ "description": "Search query.",
40
+ }
41
+ },
42
+ "required": ["query"],
43
+ },
44
+ },
45
+ }
46
+
47
+ WEB_SEARCH_TOOL_NAME = "web-search"
48
+ DEFAULT_MAX_RESULTS = 10
49
+ TAVILY_SEARCH_URL = "https://api.tavily.com/search"
50
+ DUCKDUCKGO_HTML_URL = "https://html.duckduckgo.com/html/"
51
+ WIKIPEDIA_API_URL = "https://en.wikipedia.org/w/api.php"
52
+ USER_AGENT = (
53
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
54
+ "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
55
+ )
56
+ REQUEST_TIMEOUT_SECONDS = 30.0
57
+
58
+
59
+ def is_web_search_tool_name(name: str) -> bool:
60
+ return name.strip().lower().replace("_", "-") == WEB_SEARCH_TOOL_NAME
61
+
62
+
63
+ def query_from_arguments(arguments: Mapping[str, Any] | None) -> str:
64
+ if not arguments:
65
+ return ""
66
+ raw = arguments.get("query")
67
+ if isinstance(raw, str):
68
+ return raw.strip()
69
+ if raw is None:
70
+ return ""
71
+ return str(raw).strip()
72
+
73
+
74
+ def dumps_search_payload(payload: Mapping[str, Any]) -> str:
75
+ return json.dumps(canonical_search_payload(payload), ensure_ascii=False)
76
+
77
+
78
+ def canonical_search_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
79
+ results_raw = payload.get("results")
80
+ results: list[dict[str, Any]] = []
81
+ if isinstance(results_raw, Sequence) and not isinstance(results_raw, (str, bytes)):
82
+ for item in results_raw:
83
+ if isinstance(item, Mapping):
84
+ results.append(_canonical_result(item))
85
+ images_raw = payload.get("images")
86
+ images: list[Any] = []
87
+ if isinstance(images_raw, Sequence) and not isinstance(images_raw, (str, bytes)):
88
+ images = list(images_raw)
89
+ return {
90
+ "query": str(payload.get("query") or ""),
91
+ "follow_up_questions": payload.get("follow_up_questions"),
92
+ "answer": payload.get("answer"),
93
+ "images": images,
94
+ "results": results,
95
+ "response_time": payload.get("response_time"),
96
+ "request_id": payload.get("request_id"),
97
+ }
98
+
99
+
100
+ def _canonical_result(item: Mapping[str, Any]) -> dict[str, Any]:
101
+ if item.get("error") and not item.get("url") and not item.get("title"):
102
+ return {"error": str(item["error"])}
103
+ result: dict[str, Any] = {
104
+ "url": str(item.get("url") or ""),
105
+ "title": str(item.get("title") or ""),
106
+ "content": str(item.get("content") or ""),
107
+ "score": _as_score(item.get("score")),
108
+ "raw_content": item.get("raw_content"),
109
+ }
110
+ if item.get("error"):
111
+ result["error"] = str(item["error"])
112
+ return result
113
+
114
+
115
+ def _as_score(value: Any) -> float | None:
116
+ if value is None or value == "":
117
+ return None
118
+ try:
119
+ return float(value)
120
+ except (TypeError, ValueError):
121
+ return None
122
+
123
+
124
+ def search_web(
125
+ query: str,
126
+ *,
127
+ max_results: int = DEFAULT_MAX_RESULTS,
128
+ api_key: str | None = None,
129
+ ) -> str:
130
+ """Return a Tavily-shaped JSON string for one `web-search` observation."""
131
+
132
+ cleaned = query.strip()
133
+ started = time.perf_counter()
134
+ if not cleaned:
135
+ return dumps_search_payload(
136
+ _empty_payload(
137
+ query,
138
+ error="Search query was empty",
139
+ response_time=time.perf_counter() - started,
140
+ )
141
+ )
142
+ key = (api_key if api_key is not None else os.environ.get("TAVILY_API_KEY", "")).strip()
143
+ if key:
144
+ try:
145
+ return dumps_search_payload(
146
+ _search_tavily(cleaned, api_key=key, max_results=max_results)
147
+ )
148
+ except Exception as error:
149
+ fallback_error = f"Tavily search failed: {error}"
150
+ try:
151
+ return dumps_search_payload(
152
+ _search_without_tavily(
153
+ cleaned,
154
+ max_results=max_results,
155
+ fallback_error=fallback_error,
156
+ )
157
+ )
158
+ except Exception:
159
+ return dumps_search_payload(
160
+ _empty_payload(
161
+ cleaned,
162
+ error=fallback_error,
163
+ response_time=time.perf_counter() - started,
164
+ )
165
+ )
166
+ try:
167
+ return dumps_search_payload(
168
+ _search_without_tavily(cleaned, max_results=max_results)
169
+ )
170
+ except Exception as error:
171
+ return dumps_search_payload(
172
+ _empty_payload(
173
+ cleaned,
174
+ error=str(error),
175
+ response_time=time.perf_counter() - started,
176
+ )
177
+ )
178
+
179
+
180
+ def _empty_payload(
181
+ query: str,
182
+ *,
183
+ error: str,
184
+ response_time: float | None,
185
+ ) -> dict[str, Any]:
186
+ return {
187
+ "query": query,
188
+ "follow_up_questions": None,
189
+ "answer": None,
190
+ "images": [],
191
+ "results": [{"error": error}],
192
+ "response_time": response_time,
193
+ "request_id": None,
194
+ }
195
+
196
+
197
+ def _search_tavily(
198
+ query: str,
199
+ *,
200
+ api_key: str,
201
+ max_results: int,
202
+ ) -> dict[str, Any]:
203
+ body = json.dumps(
204
+ {
205
+ "query": query,
206
+ "max_results": max(1, min(int(max_results), 20)),
207
+ "include_answer": True,
208
+ "include_raw_content": False,
209
+ "include_images": False,
210
+ "search_depth": "basic",
211
+ }
212
+ ).encode("utf-8")
213
+ payload = _read_json(
214
+ TAVILY_SEARCH_URL,
215
+ data=body,
216
+ headers={
217
+ "Authorization": f"Bearer {api_key}",
218
+ "Content-Type": "application/json",
219
+ "Accept": "application/json",
220
+ },
221
+ method="POST",
222
+ )
223
+ if not isinstance(payload, dict):
224
+ raise RuntimeError("Tavily returned a non-object payload")
225
+ payload.setdefault("query", query)
226
+ return payload
227
+
228
+
229
+ def _search_without_tavily(
230
+ query: str,
231
+ *,
232
+ max_results: int,
233
+ fallback_error: str | None = None,
234
+ ) -> dict[str, Any]:
235
+ started = time.perf_counter()
236
+ results: list[dict[str, Any]] = []
237
+ errors: list[str] = []
238
+ if fallback_error:
239
+ errors.append(fallback_error)
240
+ try:
241
+ results.extend(_search_duckduckgo_html(query, max_results=max_results))
242
+ except Exception as error:
243
+ errors.append(f"DuckDuckGo search failed: {error}")
244
+ if len(results) < max_results:
245
+ try:
246
+ wiki = _search_wikipedia(query, max_results=max_results)
247
+ seen = {item["url"] for item in results}
248
+ for item in wiki:
249
+ if item["url"] in seen:
250
+ continue
251
+ results.append(item)
252
+ if len(results) >= max_results:
253
+ break
254
+ except Exception as error:
255
+ errors.append(f"Wikipedia search failed: {error}")
256
+ elapsed = time.perf_counter() - started
257
+ if not results:
258
+ message = "; ".join(errors) if errors else "Search returned no results"
259
+ return _empty_payload(query, error=message, response_time=elapsed)
260
+ clipped = results[: max(1, min(int(max_results), 20))]
261
+ for index, item in enumerate(clipped):
262
+ if item.get("score") is None:
263
+ item["score"] = round(max(0.05, 1.0 - (index * 0.07)), 8)
264
+ item.setdefault("raw_content", None)
265
+ answer = clipped[0].get("content") if clipped else None
266
+ if isinstance(answer, str) and not answer.strip():
267
+ answer = None
268
+ return {
269
+ "query": query,
270
+ "follow_up_questions": None,
271
+ "answer": answer,
272
+ "images": [],
273
+ "results": clipped,
274
+ "response_time": elapsed,
275
+ "request_id": None,
276
+ }
277
+
278
+
279
+ def _search_duckduckgo_html(query: str, *, max_results: int) -> list[dict[str, Any]]:
280
+ encoded = urlencode({"q": query, "kl": "us-en"})
281
+ html = _read_text(
282
+ DUCKDUCKGO_HTML_URL,
283
+ data=encoded.encode("utf-8"),
284
+ headers={
285
+ "Content-Type": "application/x-www-form-urlencoded",
286
+ "Accept": "text/html",
287
+ },
288
+ method="POST",
289
+ )
290
+ parser = _DuckDuckGoParser()
291
+ parser.feed(html)
292
+ parser.close()
293
+ results: list[dict[str, Any]] = []
294
+ seen: set[str] = set()
295
+ for item in parser.results:
296
+ url = item["url"]
297
+ if not url or url in seen:
298
+ continue
299
+ seen.add(url)
300
+ results.append(
301
+ {
302
+ "url": url,
303
+ "title": item["title"],
304
+ "content": item["content"],
305
+ "score": None,
306
+ "raw_content": None,
307
+ }
308
+ )
309
+ if len(results) >= max_results:
310
+ break
311
+ return results
312
+
313
+
314
+ def _search_wikipedia(query: str, *, max_results: int) -> list[dict[str, Any]]:
315
+ params = urlencode(
316
+ {
317
+ "action": "query",
318
+ "list": "search",
319
+ "srsearch": query,
320
+ "srlimit": max(1, min(int(max_results), 10)),
321
+ "srprop": "snippet",
322
+ "format": "json",
323
+ "utf8": "1",
324
+ }
325
+ )
326
+ payload = _read_json(
327
+ f"{WIKIPEDIA_API_URL}?{params}",
328
+ headers={"Accept": "application/json"},
329
+ method="GET",
330
+ )
331
+ if not isinstance(payload, dict):
332
+ return []
333
+ query_block = payload.get("query")
334
+ hits = query_block.get("search") if isinstance(query_block, dict) else None
335
+ if not isinstance(hits, list):
336
+ return []
337
+ results: list[dict[str, Any]] = []
338
+ for hit in hits:
339
+ if not isinstance(hit, dict):
340
+ continue
341
+ title = str(hit.get("title") or "").strip()
342
+ if not title:
343
+ continue
344
+ snippet = unescape(_strip_tags(str(hit.get("snippet") or ""))).strip()
345
+ results.append(
346
+ {
347
+ "url": f"https://en.wikipedia.org/wiki/{quote(title.replace(' ', '_'))}",
348
+ "title": title,
349
+ "content": snippet,
350
+ "score": None,
351
+ "raw_content": None,
352
+ }
353
+ )
354
+ return results
355
+
356
+
357
+ def unwrap_ddg_url(href: str) -> str:
358
+ raw = href.strip()
359
+ if raw.startswith("//"):
360
+ raw = f"https:{raw}"
361
+ parsed = urlparse(raw)
362
+ host = parsed.netloc.lower()
363
+ if "duckduckgo.com" in host:
364
+ values = parse_qs(parsed.query).get("uddg") or []
365
+ if values:
366
+ return unquote(values[0])
367
+ return raw
368
+
369
+
370
+ def _strip_tags(value: str) -> str:
371
+ return _TagStripper.strip(value)
372
+
373
+
374
+ class _TagStripper(HTMLParser):
375
+ def __init__(self) -> None:
376
+ super().__init__()
377
+ self.chunks: list[str] = []
378
+
379
+ def handle_data(self, data: str) -> None:
380
+ self.chunks.append(data)
381
+
382
+ @classmethod
383
+ def strip(cls, value: str) -> str:
384
+ parser = cls()
385
+ parser.feed(value)
386
+ parser.close()
387
+ return "".join(parser.chunks)
388
+
389
+
390
+ class _DuckDuckGoParser(HTMLParser):
391
+ def __init__(self) -> None:
392
+ super().__init__()
393
+ self.results: list[dict[str, str]] = []
394
+ self._in_title = False
395
+ self._in_snippet = False
396
+ self._href = ""
397
+ self._buffer: list[str] = []
398
+ self._pending: dict[str, str] | None = None
399
+
400
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
401
+ classes = {item for item in (dict(attrs).get("class") or "").split() if item}
402
+ if tag == "a" and "result__a" in classes:
403
+ self._flush_pending()
404
+ self._in_title = True
405
+ self._href = dict(attrs).get("href") or ""
406
+ self._buffer = []
407
+ return
408
+ if tag in {"a", "div"} and "result__snippet" in classes:
409
+ self._in_snippet = True
410
+ self._buffer = []
411
+
412
+ def handle_endtag(self, tag: str) -> None:
413
+ if tag == "a" and self._in_title:
414
+ title = unescape("".join(self._buffer)).strip()
415
+ url = unwrap_ddg_url(self._href)
416
+ self._pending = {"title": title, "url": url, "content": ""}
417
+ self._in_title = False
418
+ self._buffer = []
419
+ return
420
+ if self._in_snippet and tag in {"a", "div"}:
421
+ snippet = unescape("".join(self._buffer)).strip()
422
+ if self._pending is not None:
423
+ self._pending["content"] = snippet
424
+ self.results.append(self._pending)
425
+ self._pending = None
426
+ self._in_snippet = False
427
+ self._buffer = []
428
+
429
+ def handle_data(self, data: str) -> None:
430
+ if self._in_title or self._in_snippet:
431
+ self._buffer.append(data)
432
+
433
+ def close(self) -> None:
434
+ self._flush_pending()
435
+ super().close()
436
+
437
+ def _flush_pending(self) -> None:
438
+ if self._pending is None:
439
+ return
440
+ self.results.append(self._pending)
441
+ self._pending = None
442
+
443
+
444
+ def _read_json(
445
+ url: str,
446
+ *,
447
+ data: bytes | None = None,
448
+ headers: Mapping[str, str],
449
+ method: str,
450
+ ) -> Any:
451
+ text = _read_text(url, data=data, headers=headers, method=method)
452
+ return json.loads(text)
453
+
454
+
455
+ def _read_text(
456
+ url: str,
457
+ *,
458
+ data: bytes | None = None,
459
+ headers: Mapping[str, str],
460
+ method: str,
461
+ ) -> str:
462
+ merged = {"User-Agent": USER_AGENT, **dict(headers)}
463
+ request = Request(url, data=data, headers=merged, method=method)
464
+ try:
465
+ with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
466
+ raw = response.read()
467
+ charset = response.headers.get_content_charset() or "utf-8"
468
+ except HTTPError as error:
469
+ detail = error.read().decode("utf-8", errors="replace")
470
+ raise RuntimeError(
471
+ f"{error.code} {error.reason} from {url}: {detail[:500]}"
472
+ ) from error
473
+ except URLError as error:
474
+ raise RuntimeError(f"Could not reach {url}: {error.reason}") from error
475
+ return raw.decode(charset, errors="replace")
tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
tokenizer_config.json ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_bos_token": false,
3
+ "add_eos_token": false,
4
+ "bos_token": "<|begin_of_text|>",
5
+ "eos_token": "<|end_of_text|>",
6
+ "pad_token": "<|padding|>",
7
+ "unk_token": "<|unknown|>",
8
+ "model_max_length": 2048,
9
+ "clean_up_tokenization_spaces": false,
10
+ "tokenizer_class": "PreTrainedTokenizerFast",
11
+ "chat_template": "{%- set ns = namespace(xml_tools=none, tools_emitted=false) -%}\n{%- if xml_tools is defined and xml_tools -%}\n{%- set ns.xml_tools = xml_tools -%}\n{%- elif tools is defined and tools -%}\n{%- set ns.xml_tools = tools -%}\n{%- endif -%}\n{%- set tools_preamble = 'You may call one or more functions to assist with the user query.\\nYou are provided with function signatures within <tools></tools> XML tags:\\n<tools>\\n' -%}\n{%- set tools_epilogue = '</tools>\\n\\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\\n<tool_call>\\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\\n</tool_call>' -%}\n{%- for message in messages -%}\n{%- if loop.first -%}{{- bos_token -}}{%- endif -%}\n{%- if loop.first and ns.xml_tools and message['role'] != 'system' -%}\n{{- '<|im_start|>system\\n' + tools_preamble -}}\n{%- for tool in ns.xml_tools -%}\n{%- if tool is string -%}{{- (tool | replace('<tools>', '') | replace('</tools>', '') | trim) + '\\n' -}}\n{%- else -%}{{- tool | tojson + '\\n' -}}\n{%- endif -%}\n{%- endfor -%}\n{{- tools_epilogue + '<|im_end|>\\n' -}}\n{%- set ns.tools_emitted = true -%}\n{%- endif -%}\n{%- set raw_role = message['role'] -%}\n{%- set role = 'user' if raw_role == 'tool' or raw_role == 'function' else raw_role -%}\n{%- set content_text = namespace(value='') -%}\n{%- if message['content'] is string -%}\n{%- set content_text.value = message['content'] -%}\n{%- elif message['content'] is iterable -%}\n{%- for item in message['content'] -%}\n{%- if item['type'] == 'text' -%}{%- set content_text.value = content_text.value + item['text'] -%}{%- endif -%}\n{%- endfor -%}\n{%- endif -%}\n{%- set is_tool = raw_role == 'tool' or raw_role == 'function' -%}\n{%- set prev_is_tool = loop.previtem is defined and (loop.previtem['role'] == 'tool' or loop.previtem['role'] == 'function') -%}\n{%- set next_is_tool = loop.nextitem is defined and (loop.nextitem['role'] == 'tool' or loop.nextitem['role'] == 'function') -%}\n{%- if raw_role == 'system' and not (content_text.value | trim) and not (ns.xml_tools and not ns.tools_emitted) -%}\n{%- else -%}\n{%- if is_tool and prev_is_tool -%}\n{{- '\\n' -}}\n{%- else -%}\n{{- '<|im_start|>' + role + '\\n' -}}\n{%- endif -%}\n{%- if raw_role == 'assistant' and enable_thinking is defined -%}\n{{- ('<|think|>\\n' if enable_thinking else '<|no_think|>\\n') -}}\n{%- endif -%}\n{%- if is_tool -%}\n{{- '<|tool_response|>\\n' + (content_text.value | replace('<tool_response>', '') | replace('</tool_response>', '') | trim) -}}\n{%- elif raw_role == 'system' -%}\n{{- content_text.value | replace('/system_override', '') | replace('/no_think', '') | replace('/think', '') | trim -}}\n{%- else -%}\n{{- content_text.value -}}\n{%- endif -%}\n{%- if raw_role == 'system' and ns.xml_tools and not ns.tools_emitted and '<tools>' not in content_text.value -%}\n{%- if content_text.value | trim -%}{{- '\\n\\n' -}}{%- endif -%}\n{{- tools_preamble -}}\n{%- for tool in ns.xml_tools -%}\n{%- if tool is string -%}{{- (tool | replace('<tools>', '') | replace('</tools>', '') | trim) + '\\n' -}}\n{%- else -%}{{- tool | tojson + '\\n' -}}\n{%- endif -%}\n{%- endfor -%}\n{{- tools_epilogue -}}\n{%- set ns.tools_emitted = true -%}\n{%- endif -%}\n{%- if raw_role == 'assistant' and message['tool_calls'] is defined and message['tool_calls'] -%}\n{%- if '<tool_call>' not in content_text.value -%}\n{%- for tool_call in message['tool_calls'] -%}\n{%- set fn = tool_call['function'] if tool_call['function'] is defined else tool_call -%}\n{%- if loop.first and not (content_text.value | trim) -%}\n{{- '<tool_call>\\n{\"name\": \"' + fn['name'] + '\", \"arguments\": ' -}}\n{%- else -%}\n{{- '\\n<tool_call>\\n{\"name\": \"' + fn['name'] + '\", \"arguments\": ' -}}\n{%- endif -%}\n{%- if fn['arguments'] is string -%}{{- fn['arguments'] -}}\n{%- else -%}{{- fn['arguments'] | tojson -}}\n{%- endif -%}\n{{- '}\\n</tool_call>' -}}\n{%- endfor -%}\n{%- endif -%}\n{%- endif -%}\n{%- if not (is_tool and next_is_tool) -%}\n{{- '<|im_end|>\\n' -}}\n{%- endif -%}\n{%- endif -%}\n{%- endfor -%}\n{%- if add_generation_prompt -%}\n{{- '<|im_start|>assistant\\n' -}}\n{%- if enable_thinking is defined -%}{{- ('<|think|>\\n' if enable_thinking else '<|no_think|>\\n') -}}{%- endif -%}\n{%- endif -%}",
12
+ "extra_special_tokens": [
13
+ "<|im_start|>",
14
+ "<|im_end|>",
15
+ "<|tool_call|>",
16
+ "<|tool_response|>",
17
+ "<think>",
18
+ "</think>",
19
+ "<|fim_prefix|>",
20
+ "<|fim_middle|>",
21
+ "<|fim_suffix|>",
22
+ "<|fim_pad|>",
23
+ "<|no_think|>",
24
+ "<|think|>",
25
+ "<|reserved_002|>",
26
+ "<|reserved_003|>",
27
+ "<|reserved_004|>",
28
+ "<|reserved_005|>",
29
+ "<|reserved_006|>",
30
+ "<|reserved_007|>",
31
+ "<|reserved_008|>",
32
+ "<|reserved_009|>",
33
+ "<|reserved_010|>",
34
+ "<|reserved_011|>",
35
+ "<|reserved_012|>",
36
+ "<|reserved_013|>",
37
+ "<|reserved_014|>",
38
+ "<|reserved_015|>",
39
+ "<|reserved_016|>",
40
+ "<|reserved_017|>",
41
+ "<|reserved_018|>",
42
+ "<|reserved_019|>",
43
+ "<|reserved_020|>",
44
+ "<|reserved_021|>",
45
+ "<|reserved_022|>",
46
+ "<|reserved_023|>",
47
+ "<|reserved_024|>",
48
+ "<|reserved_025|>",
49
+ "<|reserved_026|>",
50
+ "<|reserved_027|>",
51
+ "<|reserved_028|>",
52
+ "<|reserved_029|>",
53
+ "<|reserved_030|>",
54
+ "<|reserved_031|>",
55
+ "<|reserved_032|>",
56
+ "<|reserved_033|>",
57
+ "<|reserved_034|>",
58
+ "<|reserved_035|>",
59
+ "<|reserved_036|>",
60
+ "<|reserved_037|>",
61
+ "<|reserved_038|>",
62
+ "<|reserved_039|>",
63
+ "<|reserved_040|>",
64
+ "<|reserved_041|>",
65
+ "<|reserved_042|>",
66
+ "<|reserved_043|>",
67
+ "<|reserved_044|>",
68
+ "<|reserved_045|>",
69
+ "<|reserved_046|>",
70
+ "<|reserved_047|>",
71
+ "<|reserved_048|>",
72
+ "<|reserved_049|>"
73
+ ]
74
+ }
validation.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "checkpoint": "checkpoint-00005300",
3
+ "ema": {
4
+ "assistant_targets": 97349,
5
+ "batches": 16,
6
+ "elapsed_seconds": 2.1927712520046043,
7
+ "loss": 2.0271428378822587,
8
+ "perplexity": 7.592362728419498,
9
+ "targets_per_second": 44395.419682287764
10
+ },
11
+ "normal": {
12
+ "assistant_targets": 97349,
13
+ "batches": 16,
14
+ "elapsed_seconds": 6.0049016539996956,
15
+ "loss": 2.0189123026430678,
16
+ "perplexity": 7.530129975181167,
17
+ "targets_per_second": 16211.589399662953
18
+ },
19
+ "optimizer_step": 5300,
20
+ "type": "sft_validation"
21
+ }
vocab.json ADDED
The diff for this file is too large to render. See raw diff
 
windows_fla_patches/fla/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2023-2026, Songlin Yang, Yu Zhang, Zhiyuan Li
2
+ #
3
+ # Keep package import light. Eagerly importing fla.layers pulls every Triton
4
+ # kernel and breaks on Windows + Triton 3.7.
5
+
6
+ from pkgutil import extend_path
7
+
8
+ __path__ = extend_path(__path__, __name__)
9
+ __version__ = "0.5.2"
10
+ __all__: list[str] = []
windows_fla_patches/fla/layers/__init__.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2023-2026, Songlin Yang, Yu Zhang, Zhiyuan Li
2
+ #
3
+ # Lazy layer exports — avoid compiling every Triton kernel at import time.
4
+
5
+ from __future__ import annotations
6
+
7
+ import importlib
8
+ from typing import Any
9
+
10
+ _EXPORTS: dict[str, tuple[str, str]] = {
11
+ "ABCAttention": (".abc", "ABCAttention"),
12
+ "Attention": (".attn", "Attention"),
13
+ "BasedLinearAttention": (".based", "BasedLinearAttention"),
14
+ "BitAttention": (".bitattn", "BitAttention"),
15
+ "Comba": (".comba", "Comba"),
16
+ "DeltaNet": (".delta_net", "DeltaNet"),
17
+ "DeltaFormerAttention": (".deltaformer", "DeltaFormerAttention"),
18
+ "ForgettingAttention": (".forgetting_attn", "ForgettingAttention"),
19
+ "GatedDeltaNet": (".gated_deltanet", "GatedDeltaNet"),
20
+ "GatedDeltaProduct": (".gated_deltaproduct", "GatedDeltaProduct"),
21
+ "GatedDeltaNet2": (".gdn2", "GatedDeltaNet2"),
22
+ "GatedLinearAttention": (".gla", "GatedLinearAttention"),
23
+ "GatedSlotAttention": (".gsa", "GatedSlotAttention"),
24
+ "HGRNAttention": (".hgrn", "HGRNAttention"),
25
+ "HGRN2Attention": (".hgrn2", "HGRN2Attention"),
26
+ "KimiDeltaAttention": (".kda", "KimiDeltaAttention"),
27
+ "LightNetAttention": (".lightnet", "LightNetAttention"),
28
+ "LinearAttention": (".linear_attn", "LinearAttention"),
29
+ "LogLinearMamba2": (".log_linear_mamba2", "LogLinearMamba2"),
30
+ "Mamba": (".mamba", "Mamba"),
31
+ "Mamba2": (".mamba2", "Mamba2"),
32
+ "Mamba3": (".mamba3", "Mamba3"),
33
+ "MesaNet": (".mesa_net", "MesaNet"),
34
+ "MultiheadLatentAttention": (".mla", "MultiheadLatentAttention"),
35
+ "MoBA": (".moba", "MoBA"),
36
+ "MomAttention": (".mom", "MomAttention"),
37
+ "MultiScaleRetention": (".multiscale_retention", "MultiScaleRetention"),
38
+ "NativeSparseAttention": (".nsa", "NativeSparseAttention"),
39
+ "Parallax": (".parallax", "Parallax"),
40
+ "PaTHAttention": (".path_attn", "PaTHAttention"),
41
+ "Raven": (".raven", "Raven"),
42
+ "ReBasedLinearAttention": (".rebased", "ReBasedLinearAttention"),
43
+ "RodimusAttention": (".rodimus", "RodimusAttention"),
44
+ "SlidingWindowSharedKeyAttention": (".rodimus", "SlidingWindowSharedKeyAttention"),
45
+ "RWKV6Attention": (".rwkv6", "RWKV6Attention"),
46
+ "RWKV7Attention": (".rwkv7", "RWKV7Attention"),
47
+ "WallAttention": (".wall_attn", "WallAttention"),
48
+ "YOCOCrossAttention": (".yoco", "YOCOCrossAttention"),
49
+ "YOCOGatedRetention": (".yoco", "YOCOGatedRetention"),
50
+ "YOCOSharedKVBuilder": (".yoco", "YOCOSharedKVBuilder"),
51
+ }
52
+
53
+ __all__ = list(_EXPORTS)
54
+
55
+
56
+ def __getattr__(name: str) -> Any:
57
+ spec = _EXPORTS.get(name)
58
+ if spec is None:
59
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
60
+ module_name, attr = spec
61
+ value = getattr(importlib.import_module(module_name, __name__), attr)
62
+ globals()[name] = value
63
+ return value
windows_fla_patches/fla/ops/__init__.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2023-2026, Songlin Yang, Yu Zhang, Zhiyuan Li
2
+ #
3
+ # Lazy public exports so importing fla.ops.utils / fla.ops.gdn2 does not
4
+ # eagerly compile every Triton kernel (needed on Windows + Triton 3.7).
5
+
6
+ from __future__ import annotations
7
+
8
+ import importlib
9
+ from typing import Any
10
+
11
+ _EXPORTS: dict[str, str] = {
12
+ "chunk_abc": "fla.ops.abc",
13
+ "parallel_attn": "fla.ops.attn",
14
+ "fused_attnres": "fla.ops.attnres",
15
+ "fused_chunk_based": "fla.ops.based",
16
+ "parallel_based": "fla.ops.based",
17
+ "chunk_comba": "fla.ops.comba",
18
+ "fused_recurrent_comba": "fla.ops.comba",
19
+ "chunk_delta_rule": "fla.ops.delta_rule",
20
+ "fused_chunk_delta_rule": "fla.ops.delta_rule",
21
+ "fused_recurrent_delta_rule": "fla.ops.delta_rule",
22
+ "parallel_forgetting_attn": "fla.ops.forgetting_attn",
23
+ "chunk_gated_delta_rule": "fla.ops.gated_delta_rule",
24
+ "chunk_gdn": "fla.ops.gated_delta_rule",
25
+ "fused_recurrent_gated_delta_rule": "fla.ops.gated_delta_rule",
26
+ "fused_recurrent_gdn": "fla.ops.gated_delta_rule",
27
+ "chunk_dplr_delta_rule": "fla.ops.generalized_delta_rule",
28
+ "chunk_iplr_delta_rule": "fla.ops.generalized_delta_rule",
29
+ "fused_recurrent_dplr_delta_rule": "fla.ops.generalized_delta_rule",
30
+ "fused_recurrent_iplr_delta_rule": "fla.ops.generalized_delta_rule",
31
+ "chunk_gla": "fla.ops.gla",
32
+ "fused_chunk_gla": "fla.ops.gla",
33
+ "fused_recurrent_gla": "fla.ops.gla",
34
+ "chunk_gsa": "fla.ops.gsa",
35
+ "fused_recurrent_gsa": "fla.ops.gsa",
36
+ "fused_recurrent_hgrn": "fla.ops.hgrn",
37
+ "chunk_kda": "fla.ops.kda",
38
+ "fused_recurrent_kda": "fla.ops.kda",
39
+ "chunk_lightning_attn": "fla.ops.lightning_attn",
40
+ "fused_recurrent_lightning_attn": "fla.ops.lightning_attn",
41
+ "chunk_linear_attn": "fla.ops.linear_attn",
42
+ "fused_chunk_linear_attn": "fla.ops.linear_attn",
43
+ "fused_recurrent_linear_attn": "fla.ops.linear_attn",
44
+ "chunk_log_linear_attn": "fla.ops.log_linear_attn",
45
+ "chunk_mesa_net": "fla.ops.mesa_net",
46
+ "parallel_nsa": "fla.ops.nsa",
47
+ "parallel_parallax": "fla.ops.parallax",
48
+ "parallel_path_attn": "fla.ops.path_attn",
49
+ "chunk_retention": "fla.ops.retention",
50
+ "fused_chunk_retention": "fla.ops.retention",
51
+ "fused_recurrent_retention": "fla.ops.retention",
52
+ "parallel_retention": "fla.ops.retention",
53
+ "chunk_rwkv6": "fla.ops.rwkv6",
54
+ "fused_recurrent_rwkv6": "fla.ops.rwkv6",
55
+ "chunk_rwkv7": "fla.ops.rwkv7",
56
+ "fused_recurrent_rwkv7": "fla.ops.rwkv7",
57
+ "chunk_simple_gla": "fla.ops.simple_gla",
58
+ "fused_chunk_simple_gla": "fla.ops.simple_gla",
59
+ "fused_recurrent_simple_gla": "fla.ops.simple_gla",
60
+ "parallel_simple_gla": "fla.ops.simple_gla",
61
+ "parallel_wall_attn": "fla.ops.wall_attn",
62
+ "parallel_wall_attn_decode": "fla.ops.wall_attn",
63
+ "chunk_gdn2": "fla.ops.gdn2",
64
+ "fused_recurrent_gdn2": "fla.ops.gdn2",
65
+ }
66
+
67
+ __all__ = list(_EXPORTS)
68
+
69
+
70
+ def __getattr__(name: str) -> Any:
71
+ module_name = _EXPORTS.get(name)
72
+ if module_name is None:
73
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
74
+ value = getattr(importlib.import_module(module_name), name)
75
+ globals()[name] = value
76
+ return value
windows_fla_patches/fla/ops/simple_gla/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2023-2026, Songlin Yang, Yu Zhang, Zhiyuan Li
2
+ #
3
+ # This source code is licensed under the MIT license found in the
4
+ # LICENSE file in the root directory of this source tree.
5
+ # For a list of all contributors, visit:
6
+ # https://github.com/fla-org/flash-linear-attention/graphs/contributors
7
+
8
+ from .chunk import chunk_simple_gla
9
+ from .fused_chunk import fused_chunk_simple_gla
10
+ from .fused_recurrent import fused_recurrent_simple_gla
11
+
12
+ # Triton 3.7 on Windows can fail while decorating parallel kernels at import time.
13
+ try:
14
+ from .parallel import parallel_simple_gla
15
+ except Exception: # noqa: BLE001
16
+ parallel_simple_gla = None
17
+
18
+ __all__ = [
19
+ 'chunk_simple_gla',
20
+ 'fused_chunk_simple_gla',
21
+ 'fused_recurrent_simple_gla',
22
+ 'parallel_simple_gla',
23
+ ]