ryugyosoft commited on
Commit
aff4893
·
verified ·
1 Parent(s): 29d8491

onw 3.5.0: benchmark page (prefill + decode at 128-8K tokens) instead of the check page; prefill / decode speed in the chat header

Browse files
CHANGELOG.md CHANGED
@@ -5,6 +5,10 @@
5
  日付は日本時間です。各版は Hugging Face の `ryugyosoft/onw` にタグ(`v0.4.2` 以降)付きで残っています。
6
  onw ウィンドウは、新しい版があるとこのファイルから変更点を表示します(`## v…` の見出しで区切っています)。
7
 
 
 
 
 
8
  ## v3.4.0 (2026-10-30)
9
  - **API からモデルを切り替えられるように**:`/v1/models` にこのパソコンにあるモデルが全部出て、リクエストの `model` に別のモデルの ID を指定すると、今のモデルを閉じてそのモデルを読み込んでから答えます(同時に読み込むのは 1 つだけ)。知らない名前のときは今のモデルが答えます。設定「API からモデルを切り替えられるようにする」でオフにできます。
10
  - `/v1/models` にモデルの上限(`context_length`・`max_model_len`・`max_context_length`・`meta.n_ctx`、`max_output_tokens`)を入れました。
 
5
  日付は日本時間です。各版は Hugging Face の `ryugyosoft/onw` にタグ(`v0.4.2` 以降)付きで残っています。
6
  onw ウィンドウは、新しい版があるとこのファイルから変更点を表示します(`## v…` の見出しで区切っています)。
7
 
8
+ ## v3.5.0 (2026-10-31)
9
+ - 検証ページを **ベンチマーク**(`/bench`)に変えました。128・1K・2K・4K(8K も選べます)トークンのプロンプトで、プロンプトの読み込み(プリフィル)の速さと、そのあとの生成(デコード)の速さを表にします。プロンプトはそのモデル自身のトークナイザーで長さを合わせ、毎回違う一文で始めるので前の会話は再利用されません。「結果をコピー(Markdown)」で、アプリ・モデル・CPU・OS と一緒に表をコピーできます(記事などに)。トレイとウィンドウの「検証ページ」も「ベンチマーク」になり、前のアドレス(`/check`)はベンチマークへ移ります
10
+ - チャット画面の上部に、直前の回答のプリフィル速度と生成速度を出すようにしました
11
+
12
  ## v3.4.0 (2026-10-30)
13
  - **API からモデルを切り替えられるように**:`/v1/models` にこのパソコンにあるモデルが全部出て、リクエストの `model` に別のモデルの ID を指定すると、今のモデルを閉じてそのモデルを読み込んでから答えます(同時に読み込むのは 1 つだけ)。知らない名前のときは今のモデルが答えます。設定「API からモデルを切り替えられるようにする」でオフにできます。
14
  - `/v1/models` にモデルの上限(`context_length`・`max_model_len`・`max_context_length`・`meta.n_ctx`、`max_output_tokens`)を入れました。
CHANGELOG_en.md CHANGED
@@ -4,6 +4,10 @@
4
 
5
  Dates are JST. Every version is on Hugging Face `ryugyosoft/onw`, tagged from `v0.4.2` on.
6
 
 
 
 
 
7
  ## v3.4.0 (2026-10-30)
8
  - **Model choice from the API**: `/v1/models` lists every model on this PC, and a request whose `model` names another one closes the running model and loads that one first (one model at a time). A name the server does not know is answered by the running model. The setting "Let API requests switch models" turns it off.
9
  - `/v1/models` carries the model's limits (`context_length`, `max_model_len`, `max_context_length`, `meta.n_ctx`, `max_output_tokens`).
 
4
 
5
  Dates are JST. Every version is on Hugging Face `ryugyosoft/onw`, tagged from `v0.4.2` on.
6
 
7
+ ## v3.5.0 (2026-10-31)
8
+ - The check page is now a **benchmark** (`/bench`): prompts of 128, 1K, 2K and 4K tokens (8K can be added), with how fast the prompt is read (prefill) and how fast the answer is then generated (decode). The prompts are cut to length by the model's own tokenizer and start with a different sentence every time, so nothing of an earlier conversation is reused. "Copy the results (Markdown)" copies the table with the app, model, CPU and OS (for articles and posts). The tray's and the window's "check page" entries open the benchmark, and the old address (`/check`) leads there
9
+ - The chat page shows the last answer's prefill and decode speed at the top
10
+
11
  ## v3.4.0 (2026-10-30)
12
  - **Model choice from the API**: `/v1/models` lists every model on this PC, and a request whose `model` names another one closes the running model and loads that one first (one model at a time). A name the server does not know is answered by the running model. The setting "Let API requests switch models" turns it off.
13
  - `/v1/models` carries the model's limits (`context_length`, `max_model_len`, `max_context_length`, `meta.n_ctx`, `max_output_tokens`).
CONVERT_LFM.md CHANGED
@@ -112,8 +112,8 @@ src=~/lfm-src/LFM2.5-2.6B
112
 
113
  ### 5. 動作を確かめる
114
 
115
- - 「検証ページ」を開いて「検証を実行」を押し、すべての項目が「OK」になることを確かめます。生成速度の目安は
116
- 上の表のとおりです(PC によって変わります)。
117
  - LFM2.5 は、既定では考えずにすぐ答えます。考えさせたいときは、チャット画面の「思考モード」をオンにするか、API で
118
  `"chat_template_kwargs": {"enable_thinking": true}` を指定します。
119
 
@@ -144,8 +144,8 @@ src=~/lfm-src/LFM2.5-2.6B
144
  - 手順 1〜5 を上から順に、ユーザーの OS に合ったほう(Windows なら PowerShell、Ubuntu なら bash)だけ実行する。
145
  変数(`$py`、`$models`、`$src`)は同じシェルの中で使い続ける。
146
  - 手順 1 で onw の版が 0.9.0 未満(LFM2.5-24B-A2B では 3.3.0 未満)なら、先に進まずユーザーに更新を頼む。
147
- - 各手順の「成功の目安」(版の表示、`done in …s`、出力フォルダのファイル、検証ページの OK)を確かめてから次に進み、
148
  失敗したら「うまくいかないとき」の表に沿って対処する。
149
  - モデルの取得(数 GB のダウンロード)の前と、手順 6 の削除の前には、ユーザーに確認する。
150
  - ライセンスの判断はユーザーに任せる(上の「ライセンスについて」を伝えるだけにする)。
151
- - 終わったら、変換先のフォルダ、変換にかかった時間、検証ページの生成速度をユーザーに報告する。
 
112
 
113
  ### 5. 動作を確かめる
114
 
115
+ - チャット画面で何か聞いて、答えが返ることを確かめます。速さは「ベンチマーク」を開いて「ベンチマークを実行」で測れます。
116
+ 生成速度の目安は上の表のとおりです(PC によって変わります)。
117
  - LFM2.5 は、既定では考えずにすぐ答えます。考えさせたいときは、チャット画面の「思考モード」をオンにするか、API で
118
  `"chat_template_kwargs": {"enable_thinking": true}` を指定します。
119
 
 
144
  - 手順 1〜5 を上から順に、ユーザーの OS に合ったほう(Windows なら PowerShell、Ubuntu なら bash)だけ実行する。
145
  変数(`$py`、`$models`、`$src`)は同じシェルの中で使い続ける。
146
  - 手順 1 で onw の版が 0.9.0 未満(LFM2.5-24B-A2B では 3.3.0 未満)なら、先に進まずユーザーに更新を頼む。
147
+ - 各手順の「成功の目安」(版の表示、`done in …s`、出力フォルダのファイル、チャットの答え)を確かめてから次に進み、
148
  失敗したら「うまくいかないとき」の表に沿って対処する。
149
  - モデルの取得(数 GB のダウンロード)の前と、手順 6 の削除の前には、ユーザーに確認する。
150
  - ライセンスの判断はユーザーに任せる(上の「ライセンスについて」を伝えるだけにする)。
151
+ - 終わったら、変換先のフォルダ、変換にかかった時間、ベンチマークの生成速度をユーザーに報告する。
CONVERT_LFM_en.md CHANGED
@@ -117,8 +117,8 @@ folder holds `engine.json`, `seg00.bin`..., `seg00_S1.xml`..., `shared.bin`, `to
117
 
118
  ### 5. Check it
119
 
120
- - Open the "Check page" and press "Run checks": every row should say "OK". Decode speeds should be near the table
121
- above (they vary by PC).
122
  - LFM2.5 answers without thinking by default. To let it think, switch on the chat page's thinking mode or pass
123
  `"chat_template_kwargs": {"enable_thinking": true}` in the API.
124
 
@@ -149,8 +149,8 @@ Rules for running these steps on the user's behalf:
149
  - Run steps 1-5 in order, only the variant for the user's OS (PowerShell on Windows, bash on Ubuntu), keeping the
150
  variables (`$py`, `$models`, `$src`) in the same shell.
151
  - If step 1 shows an onw version below 0.9.0 (3.3.0 for LFM2.5-24B-A2B), stop and ask the user to update first.
152
- - Check each step's success signs (the version, `done in ...s`, the files in the output folder, OK on the check page)
153
  before going on; on a failure follow the troubleshooting table.
154
  - Ask the user before downloading the model (several GB) and before deleting anything in step 6.
155
  - Leave the license decision to the user (just pass on "About the license").
156
- - When done, report the output folder, the conversion time and the decode speed from the check page.
 
117
 
118
  ### 5. Check it
119
 
120
+ - Ask something on the chat page and see that an answer comes back. "Run the benchmark" on the benchmark page
121
+ measures the speeds; decode should be near the table above (it varies by PC).
122
  - LFM2.5 answers without thinking by default. To let it think, switch on the chat page's thinking mode or pass
123
  `"chat_template_kwargs": {"enable_thinking": true}` in the API.
124
 
 
149
  - Run steps 1-5 in order, only the variant for the user's OS (PowerShell on Windows, bash on Ubuntu), keeping the
150
  variables (`$py`, `$models`, `$src`) in the same shell.
151
  - If step 1 shows an onw version below 0.9.0 (3.3.0 for LFM2.5-24B-A2B), stop and ask the user to update first.
152
+ - Check each step's success signs (the version, `done in ...s`, the files in the output folder, an answer on the chat page)
153
  before going on; on a failure follow the troubleshooting table.
154
  - Ask the user before downloading the model (several GB) and before deleting anything in step 6.
155
  - Leave the license decision to the user (just pass on "About the license").
156
+ - When done, report the output folder, the conversion time and the decode speed from the benchmark page.
MANUAL.md CHANGED
@@ -9,7 +9,7 @@
9
  - [「モデル」タブ:モデルを選んでダウンロード](#「モデル」タブ:モデルを選んでダウンロード)
10
  - [「サーバー」タブ:読み込んで使う](#「サーバー」タブ:読み込んで使う)
11
  - [チャット画面](#チャット画面)
12
- - [検証ページ](#検証ページ)
13
  - [「設定」タブ](#「設定」タブ)
14
  - [他のアプリから使う(OpenAI 互換 API ほか)](#他のアプリから使う(openai-互換-api-ほか))
15
  - [更新](#更新)
@@ -26,7 +26,7 @@
26
  | メニュー | 内容 |
27
  |---|---|
28
  | onw を開く(モデル・サーバー・設定) | onw ウィンドウを開きます(アイコンのダブルクリックでも開きます) |
29
- | チャットを開く/検証ページを開く | ブラウザでチャット画面・検証ページを開きます(モデルが動作中のとき) |
30
  | OpenAI API のURLをコピー | 他のアプリに貼る base URL をコピーします |
31
  | モデルをロード/アンロード(サーバー停止) | 前回のモデルを読み込む/止めてメモリを空けます |
32
  | 終了(サーバーも停止) | onw ごと終わります |
@@ -75,7 +75,7 @@
75
  | 項目・ボタン | 内容 |
76
  |---|---|
77
  | モデル名・速度・メモリ | 動いているモデルと、その目安 |
78
- | チャット/検証ページ | ブラウザでチャット画面・検証ページを開きます |
79
  | ログ | サーバーのログ(読み込みやエラーの記録)を開きます |
80
  | アンロード | サーバーを止めてメモリを空けます |
81
  | 場所 | モデルのフォルダ |
@@ -124,12 +124,9 @@
124
  - 会話はこの PC の onw の設定フォルダ(Windows は `%APPDATA%\onw\chats`、Ubuntu は `~/.config/onw/chats`)にだけ保存されます。
125
  添付した画像は、縮小した写しが保存されます。
126
 
127
- ## 検証ページ
128
 
129
- <img src="images/onw-check.png" width="760" alt="検証ページで検証を実行したところ">
130
-
131
- 「検証を実行」で、テキスト・先読み検証・2 ターンの会話・画像(読めるモデルのとき)を順に試し、処理時間と生成速度を
132
- 表にします。動作確認や、設定を変えたときの速さの比較に使えます。「繰り返し抑制」は検証のリクエストに付けて送る値です(Gemma では 1.2 以上を推奨)。
133
 
134
  ## 「設定」タブ
135
 
@@ -256,7 +253,7 @@ Microsoft Store 版の Python で入れた場合、設定とモデルはその P
256
  |---|---|
257
  | 最初のロードがなかなか終わらない | 初回は NPU 向けのコンパイルに 10〜30 分かかります。「サーバー」タブの「NPU 向けの準備 k/n」が進んでいれば正常です |
258
  | メモリが足りない | 設定の「メモリ節約モード」(と「厳格」)、Qwen3.6 系なら「エキスパートのメモリ」を試してください。コンテキスト長を小さくしても、メモリはほとんど減りません |
259
- | 同じ言葉を何度も繰り返す(例:「訪れる訪れる訪れる…」) | 温度 0 では、一度繰り返しに入ると抜け出せないことがあります(特に Gemma。NPU の計算は CPU とわずかに違うため、CPU では出ない質問でも起きます)。チャット画面や検証ページの「繰り返し抑制」を 1.2 くらいにしてください。Gemma を読み込むと、横に赤字で推奨値が出ます。API では `repetition_penalty: 1.2` を付けます |
260
  | 回答が出ず、思考だけで終わる | 最大トークンが足りません。チャット画面の「最大トークン」を増やしてください(「常に考える」モデルは 2048 以上がおすすめ) |
261
  | 「タスクトレイの onw は … のまま動いています」と出る | トレイのメニューから「終了」して、onw を起動し直してください |
262
  | モデルの状態が「途中」 | ダウンロードが途中で止まっています。「削除」してからダウンロードし直してください |
 
9
  - [「モデル」タブ:モデルを選んでダウンロード](#「モデル」タブ:モデルを選んでダウンロード)
10
  - [「サーバー」タブ:読み込んで使う](#「サーバー」タブ:読み込んで使う)
11
  - [チャット画面](#チャット画面)
12
+ - [ベンチマーク](#ベンチマーク)
13
  - [「設定」タブ](#「設定」タブ)
14
  - [他のアプリから使う(OpenAI 互換 API ほか)](#他のアプリから使う(openai-互換-api-ほか))
15
  - [更新](#更新)
 
26
  | メニュー | 内容 |
27
  |---|---|
28
  | onw を開く(モデル・サーバー・設定) | onw ウィンドウを開きます(アイコンのダブルクリックでも開きます) |
29
+ | チャットを開く/ベンチマークを開く | ブラウザでチャット画面・ベンチマークを開きます(モデルが動作中のとき) |
30
  | OpenAI API のURLをコピー | 他のアプリに貼る base URL をコピーします |
31
  | モデルをロード/アンロード(サーバー停止) | 前回のモデルを読み込む/止めてメモリを空けます |
32
  | 終了(サーバーも停止) | onw ごと終わります |
 
75
  | 項目・ボタン | 内容 |
76
  |---|---|
77
  | モデル名・速度・メモリ | 動いているモデルと、その目安 |
78
+ | チャット/ベンチマーク | ブラウザでチャット画面・ベンチマークを開きます |
79
  | ログ | サーバーのログ(読み込みやエラーの記録)を開きます |
80
  | アンロード | サーバーを止めてメモリを空けます |
81
  | 場所 | モデルのフォルダ |
 
124
  - 会話はこの PC の onw の設定フォルダ(Windows は `%APPDATA%\onw\chats`、Ubuntu は `~/.config/onw/chats`)にだけ保存されます。
125
  添付した画像は、縮小した写しが保存されます。
126
 
127
+ ## ベンチマーク
128
 
129
+ 「ベンチマークを実行」で、128・1K・2K・4K(8K も選べます)トークンのプロンプトを読ませ、プロンプトの読み込み(プリフィル)の速さと、そのあとの生成(デコード)の速さを表にします。プロンプトはこのモデル自身のトークナイザーで長さを合わせ、毎回違う一文で始めるので、前の会話は再利用されません。温度 0・思考オフで、「回数」を増やすと一番速かった回を出します。「結果をコピー(Markdown)」で、アプリ・モデル・CPU・OS と一緒に表をコピーできます(記事などに)。チャット画面の上部にも、直前の回答のプリフィル速度と生成速度が出ます。
 
 
 
130
 
131
  ## 「設定」タブ
132
 
 
253
  |---|---|
254
  | 最初のロードがなかなか終わらない | 初回は NPU 向けのコンパイルに 10〜30 分かかります。「サーバー」タブの「NPU 向けの準備 k/n」が進んでいれば正常です |
255
  | メモリが足りない | 設定の「メモリ節約モード」(と「厳格」)、Qwen3.6 系なら「エキスパートのメモリ」を試してください。コンテキスト長を小さくしても、メモリはほとんど減りません |
256
+ | 同じ言葉を何度も繰り返す(例:「訪れる訪れる訪れる…」) | 温度 0 では、一度繰り返しに入ると抜け出せないことがあります(特に Gemma。NPU の計算は CPU とわずかに違うため、CPU では出ない質問でも起きます)。チャット画面の「繰り返し抑制」を 1.2 くらいにしてください。Gemma を読み込むと、横に赤字で推奨値が出ます。API では `repetition_penalty: 1.2` を付けます |
257
  | 回答が出ず、思考だけで終わる | 最大トークンが足りません。チャット画面の「最大トークン」を増やしてください(「常に考える」モデルは 2048 以上がおすすめ) |
258
  | 「タスクトレイの onw は … のまま動いています」と出る | トレイのメニューから「終了」して、onw を起動し直してください |
259
  | モデルの状態が「途中」 | ダウンロードが途中で止まっています。「削除」してからダウンロードし直してください |
MANUAL_en.md CHANGED
@@ -9,7 +9,7 @@ How to install it and the list of models are in the [README](README_en.md).
9
  - [Models tab: pick and download a model](#models-tab-pick-and-download-a-model)
10
  - [Server tab: load and use it](#server-tab-load-and-use-it)
11
  - [The chat page](#the-chat-page)
12
- - [The check page](#the-check-page)
13
  - [Settings tab](#settings-tab)
14
  - [Use it from other apps (OpenAI-compatible API and more)](#use-it-from-other-apps-openai-compatible-api-and-more)
15
  - [Updates](#updates)
@@ -26,7 +26,7 @@ Right-click the onw icon in the tray for this menu:
26
  | Menu item | What it does |
27
  |---|---|
28
  | Open onw (models, server, settings) | Opens the onw window (double-clicking the icon does too) |
29
- | Open chat / Open check page | Opens the chat page or the check page in the browser (while a model is running) |
30
  | Copy the OpenAI API URL | Copies the base URL to paste into other apps |
31
  | Load model / Unload (stop the server) | Loads the last model / stops it to free the memory |
32
  | Quit (stops the server too) | Ends onw |
@@ -81,7 +81,7 @@ Once it is running, the top card shows:
81
  | Item / button | What it is |
82
  |---|---|
83
  | Model name, speed, memory | The running model and its estimates |
84
- | Chat / Check page | Opens the chat page or the check page in the browser |
85
  | Log | Opens the server log (loading, errors) |
86
  | Unload | Stops the server and frees the memory |
87
  | Location | The model's folder |
@@ -137,13 +137,9 @@ The left panel keeps your conversations (☰ shows or hides it).
137
  - They are kept only on this PC, in onw's settings folder (`%APPDATA%\onw\chats` on Windows, `~/.config/onw/chats` on
138
  Ubuntu). Attached images are kept as small copies.
139
 
140
- ## The check page
141
 
142
- <img src="images/onw-check-en.png" width="760" alt="The check page after a run">
143
-
144
- "Run checks" tries text, prompt lookup, a two-turn conversation and an image (for models that read images) one after
145
- another and tables the processing times and decode speeds. Handy to see that everything works, or to compare speeds
146
- after changing a setting. "Repetition penalty" is sent with the check's requests (1.2 or more is recommended for Gemma).
147
 
148
  ## Settings tab
149
 
@@ -269,7 +265,7 @@ When you install again, setup asks to install the python.org Python (the Store o
269
  |---|---|
270
  | The first load takes long | The first load compiles for the NPU, 10-30 min. If "Preparing for the NPU k/n" on the Server tab moves, all is well |
271
  | Not enough memory | Try the memory-saving mode (and Strict) in the settings, and for Qwen3.6 models the expert memory. A shorter context length saves almost nothing |
272
- | It repeats the same word over and over | At temperature 0 a repetition loop may never end (Gemma especially; the NPU computes slightly differently from the CPU, so it happens for prompts that are fine on the CPU). Set "Repetition penalty" on the chat page or the check page to about 1.2; with Gemma loaded, the recommended value shows next to it in red. In the API, add `repetition_penalty: 1.2` |
273
  | Only thinking, no answer | The max tokens ran out. Raise "Max tokens" in the chat (2048 or more for "always" thinking models) |
274
  | "…the onw in the task tray still runs …" | Quit from the tray menu and start onw again |
275
  | A model's status is "Incomplete" | The download stopped part-way. Delete it and download it again |
 
9
  - [Models tab: pick and download a model](#models-tab-pick-and-download-a-model)
10
  - [Server tab: load and use it](#server-tab-load-and-use-it)
11
  - [The chat page](#the-chat-page)
12
+ - [The benchmark](#the-benchmark)
13
  - [Settings tab](#settings-tab)
14
  - [Use it from other apps (OpenAI-compatible API and more)](#use-it-from-other-apps-openai-compatible-api-and-more)
15
  - [Updates](#updates)
 
26
  | Menu item | What it does |
27
  |---|---|
28
  | Open onw (models, server, settings) | Opens the onw window (double-clicking the icon does too) |
29
+ | Open chat / Open the benchmark | Opens the chat page or the benchmark page in the browser (while a model is running) |
30
  | Copy the OpenAI API URL | Copies the base URL to paste into other apps |
31
  | Load model / Unload (stop the server) | Loads the last model / stops it to free the memory |
32
  | Quit (stops the server too) | Ends onw |
 
81
  | Item / button | What it is |
82
  |---|---|
83
  | Model name, speed, memory | The running model and its estimates |
84
+ | Chat / Benchmark | Opens the chat page or the benchmark page in the browser |
85
  | Log | Opens the server log (loading, errors) |
86
  | Unload | Stops the server and frees the memory |
87
  | Location | The model's folder |
 
137
  - They are kept only on this PC, in onw's settings folder (`%APPDATA%\onw\chats` on Windows, `~/.config/onw/chats` on
138
  Ubuntu). Attached images are kept as small copies.
139
 
140
+ ## The benchmark
141
 
142
+ "Run the benchmark" has the model read prompts of 128, 1K, 2K and 4K tokens (8K can be added) and tables how fast it reads the prompt (prefill) and how fast it then generates (decode). The prompts are cut to length by the model's own tokenizer and start with a different sentence every time, so nothing of an earlier conversation is reused. Temperature 0, thinking off; with more runs the fastest one is shown. "Copy the results (Markdown)" copies the table with the app, model, CPU and OS (for articles and posts). The chat page shows the last answer's prefill and decode speed at the top too.
 
 
 
 
143
 
144
  ## Settings tab
145
 
 
265
  |---|---|
266
  | The first load takes long | The first load compiles for the NPU, 10-30 min. If "Preparing for the NPU k/n" on the Server tab moves, all is well |
267
  | Not enough memory | Try the memory-saving mode (and Strict) in the settings, and for Qwen3.6 models the expert memory. A shorter context length saves almost nothing |
268
+ | It repeats the same word over and over | At temperature 0 a repetition loop may never end (Gemma especially; the NPU computes slightly differently from the CPU, so it happens for prompts that are fine on the CPU). Set "Repetition penalty" on the chat page to about 1.2; with Gemma loaded, the recommended value shows next to it in red. In the API, add `repetition_penalty: 1.2` |
269
  | Only thinking, no answer | The max tokens ran out. Raise "Max tokens" in the chat (2048 or more for "always" thinking models) |
270
  | "…the onw in the task tray still runs …" | Quit from the tray menu and start onw again |
271
  | A model's status is "Incomplete" | The download stopped part-way. Delete it and download it again |
README.md CHANGED
@@ -90,7 +90,7 @@ Python や git の知識は要りません。Python が入っていなければ
90
 
91
  <img src="images/onw-server.png" width="760" alt="onw ウィンドウの「サーバー」タブ(Gemma 4 E4B が動作中)">
92
 
93
- 画面ごとの詳しい使い方(モデルの管理、チャット画面と会話の履歴、検証ページ、設定の各項目、困ったとき)は
94
  **[使い方マニュアル](MANUAL.md)** にあります。
95
 
96
  ### モデル一覧
@@ -180,7 +180,7 @@ Hugging Face から取得して、手元で変換します(onw だけで変換
180
  他のアプリからは base URL に `http://localhost:8000/v1`、モデル名にウィンドウに表示される名前を指定します。
181
  `GET /` でサーバーの案内(JSON)、`/health` でモデルの読み込み完了を確認できます。CORS は開放済みで、LAN に公開する
182
  場合は API キー(`--api-key` または設定画面)で `/v1` を保護できます。動作確認用に、チャット画面(`/chat`)と、
183
- テキスト・先読み検証・2 ターン会話・画像の検証を一括実行して速度を表にする検証ページ(`/check`)もあり、どちらも
184
  トレイのメニューとウィンドウから開けます。NPU ドライバーがない環境では CPU で動きます。
185
 
186
  **ツール呼び出し(function calling)**にも対応しています。OpenAI と同じく `tools` を渡すと、モデルが呼び出しを決めたときは
@@ -254,7 +254,7 @@ KV キャッシュは INT8 で、会話が実際に使った長さの分だけ
254
  | `onw/store.py`、`onw/xio.py` | エキスパートの保管(全部メモリ/SSD から読む LRU キャッシュ)、OS のキャッシュを通さない直接読み込み |
255
  | `onw/chat.py` | 生成ループ:画像(Qwen/Gemma)、サンプリング、停止文字列、先読み検証(正確な巻き戻し、再帰状態を持つモデルでは遅延やり直し、効く場合だけ自動で有効)、ターン間の差分処理 |
256
  | `onw/models/` | アーキテクチャごとの組み立て定義:`lfm2_moe`(dense の `lfm2` も)、`qwen3_5_moe`(dense と MoE)、`qwen_vision`、`gemma4`、`gpt_oss` |
257
- | `onw/hub.py`、`onw/server.py`、`onw/web/` | モデルのダウンロードと LFS 対策、OpenAI 互換サーバー、チャット画面(`/chat`)、検証ページ(`/check`) |
258
  | `onw/app.py`、`onw/tray.py`、`onw/manager.py`、`onw/update.py`、`onw/i18n.py` | onw ウィンドウ(モデル/サーバー/設定)、トレイアイコン、ウィンドウの裏側(ローカル API)、自動更新、表示言語 |
259
  | `setup.*`、`install.*`、`find_python.bat` | インストーラー(Python の検出と winget) |
260
  | `check_ref.py`、`check_vision.py`、`test_*.py`、`probe_segments.py`、`prof_*.py`、`mem_*.py`、`bench_*.py` | 元の HF モデルとの比較、生成・画像・複数ターンの検証、区間ごとの NPU と CPU の比較、速度・メモリ計測 |
 
90
 
91
  <img src="images/onw-server.png" width="760" alt="onw ウィンドウの「サーバー」タブ(Gemma 4 E4B が動作中)">
92
 
93
+ 画面ごとの詳しい使い方(モデルの管理、チャット画面と会話の履歴、ベンチマーク、設定の各項目、困ったとき)は
94
  **[使い方マニュアル](MANUAL.md)** にあります。
95
 
96
  ### モデル一覧
 
180
  他のアプリからは base URL に `http://localhost:8000/v1`、モデル名にウィンドウに表示される名前を指定します。
181
  `GET /` でサーバーの案内(JSON)、`/health` でモデルの読み込み完了を確認できます。CORS は開放済みで、LAN に公開する
182
  場合は API キー(`--api-key` または設定画面)で `/v1` を保護できます。動作確認用に、チャット画面(`/chat`)と、
183
+ プロンプトの読み込み(プリフィル)と生成(デコード)の速さを 128〜8K トークンで測るベンチマーク(`/bench`)もあり、どちらも
184
  トレイのメニューとウィンドウから開けます。NPU ドライバーがない環境では CPU で動きます。
185
 
186
  **ツール呼び出し(function calling)**にも対応しています。OpenAI と同じく `tools` を渡すと、モデルが呼び出しを決めたときは
 
254
  | `onw/store.py`、`onw/xio.py` | エキスパートの保管(全部メモリ/SSD から読む LRU キャッシュ)、OS のキャッシュを通さない直接読み込み |
255
  | `onw/chat.py` | 生成ループ:画像(Qwen/Gemma)、サンプリング、停止文字列、先読み検証(正確な巻き戻し、再帰状態を持つモデルでは遅延やり直し、効く場合だけ自動で有効)、ターン間の差分処理 |
256
  | `onw/models/` | アーキテクチャごとの組み立て定義:`lfm2_moe`(dense の `lfm2` も)、`qwen3_5_moe`(dense と MoE)、`qwen_vision`、`gemma4`、`gpt_oss` |
257
+ | `onw/hub.py`、`onw/server.py`、`onw/web/` | モデルのダウンロードと LFS 対策、OpenAI 互換サーバー、チャット画面(`/chat`)、ベンチマーク(`/bench`) |
258
  | `onw/app.py`、`onw/tray.py`、`onw/manager.py`、`onw/update.py`、`onw/i18n.py` | onw ウィンドウ(モデル/サーバー/設定)、トレイアイコン、ウィンドウの裏側(ローカル API)、自動更新、表示言語 |
259
  | `setup.*`、`install.*`、`find_python.bat` | インストーラー(Python の検出と winget) |
260
  | `check_ref.py`、`check_vision.py`、`test_*.py`、`probe_segments.py`、`prof_*.py`、`mem_*.py`、`bench_*.py` | 元の HF モデルとの比較、生成・画像・複数ターンの検証、区間ごとの NPU と CPU の比較、速度・メモリ計測 |
README_en.md CHANGED
@@ -77,7 +77,7 @@ Three steps in the **onw window**, which opens when the install is done:
77
 
78
  <img src="images/onw-server-en.png" width="760" alt="The Server tab of the onw window (Gemma 4 E4B running)">
79
 
80
- The full guide, screen by screen (managing models, the chat page and its history, the check page, every setting,
81
  troubleshooting), is the **[user guide](MANUAL_en.md)**.
82
 
83
  ### Models
@@ -170,8 +170,8 @@ like). How to connect them: [user guide](MANUAL_en.md#use-it-from-other-apps-ope
170
 
171
  Point other apps at `http://localhost:8000/v1` with the model name shown in the window (`GET /` describes the
172
  server, `/health` answers once the model is loaded, CORS is open, `--api-key` / the settings' API key protects `/v1`).
173
- For trying things out it also serves a chat UI at `/chat` and a check page at `/check` that runs a text,
174
- prompt-lookup, two-turn and image test and shows the speeds; both open from the tray menu and the window. Without
175
  an NPU driver everything runs on the CPU.
176
 
177
  **Tool calls (function calling)** work as with OpenAI: pass `tools`, and when the model decides to call one the reply
@@ -244,7 +244,7 @@ install it anywhere with `pip install -e . --extra-index-url https://storage.ope
244
  | `onw/store.py`, `onw/xio.py` | expert storage (all in memory / LRU cache streaming from the SSD), direct I/O that bypasses the OS cache |
245
  | `onw/chat.py` | chat loop: images (Qwen / Gemma), sampling, stop strings, prompt lookup decoding (exact rewind, or lazy redo for recurrent models, switched on only when it pays), prefix reuse between turns |
246
  | `onw/models/` | one builder per architecture: `lfm2_moe` (and dense `lfm2`), `qwen3_5_moe` (dense and MoE), `qwen_vision`, `gemma4`, `gpt_oss` |
247
- | `onw/hub.py`, `onw/server.py`, `onw/web/` | model download + LFS guard, OpenAI-compatible server, chat UI (`/chat`), check page (`/check`) |
248
  | `onw/app.py`, `onw/tray.py`, `onw/manager.py`, `onw/update.py`, `onw/i18n.py` | onw window (models / server / settings), tray icon, the window's backend (local API), self-update, UI languages |
249
  | `setup.*`, `install.*`, `find_python.bat` | installers (Python detection + winget) |
250
  | `check_ref.py`, `check_vision.py`, `test_*.py`, `probe_segments.py`, `prof_*.py`, `mem_*.py`, `bench_*.py` | vs the HF model, generation / image / multi-turn checks, NPU-vs-CPU per segment, timing and memory |
 
77
 
78
  <img src="images/onw-server-en.png" width="760" alt="The Server tab of the onw window (Gemma 4 E4B running)">
79
 
80
+ The full guide, screen by screen (managing models, the chat page and its history, the benchmark, every setting,
81
  troubleshooting), is the **[user guide](MANUAL_en.md)**.
82
 
83
  ### Models
 
170
 
171
  Point other apps at `http://localhost:8000/v1` with the model name shown in the window (`GET /` describes the
172
  server, `/health` answers once the model is loaded, CORS is open, `--api-key` / the settings' API key protects `/v1`).
173
+ For trying things out it also serves a chat UI at `/chat` and a benchmark page at `/bench` that measures
174
+ prompt reading (prefill) and generation (decode) speed at 128 to 8K tokens; both open from the tray menu and the window. Without
175
  an NPU driver everything runs on the CPU.
176
 
177
  **Tool calls (function calling)** work as with OpenAI: pass `tools`, and when the model decides to call one the reply
 
244
  | `onw/store.py`, `onw/xio.py` | expert storage (all in memory / LRU cache streaming from the SSD), direct I/O that bypasses the OS cache |
245
  | `onw/chat.py` | chat loop: images (Qwen / Gemma), sampling, stop strings, prompt lookup decoding (exact rewind, or lazy redo for recurrent models, switched on only when it pays), prefix reuse between turns |
246
  | `onw/models/` | one builder per architecture: `lfm2_moe` (and dense `lfm2`), `qwen3_5_moe` (dense and MoE), `qwen_vision`, `gemma4`, `gpt_oss` |
247
+ | `onw/hub.py`, `onw/server.py`, `onw/web/` | model download + LFS guard, OpenAI-compatible server, chat UI (`/chat`), benchmark page (`/bench`) |
248
  | `onw/app.py`, `onw/tray.py`, `onw/manager.py`, `onw/update.py`, `onw/i18n.py` | onw window (models / server / settings), tray icon, the window's backend (local API), self-update, UI languages |
249
  | `setup.*`, `install.*`, `find_python.bat` | installers (Python detection + winget) |
250
  | `check_ref.py`, `check_vision.py`, `test_*.py`, `probe_segments.py`, `prof_*.py`, `mem_*.py`, `bench_*.py` | vs the HF model, generation / image / multi-turn checks, NPU-vs-CPU per segment, timing and memory |
onw/__init__.py CHANGED
@@ -2,4 +2,4 @@
2
 
3
  A small engine that runs LLMs entirely on the Intel NPU: static segment graphs + host-managed state.
4
  """
5
- __version__ = "3.4.0"
 
2
 
3
  A small engine that runs LLMs entirely on the Intel NPU: static segment graphs + host-managed state.
4
  """
5
+ __version__ = "3.5.0"
onw/i18n.json CHANGED
@@ -388,7 +388,36 @@
388
  "ダウンロードを中止しますか?\nダウンロード済みの分は残り、次はその続きから取ります。": "Stop the download?\nWhat has been downloaded stays; the next download continues from there.",
389
  "エラー": "Error",
390
  "onw の状態を読めません: {0}": "Could not read onw's state: {0}",
391
- "Lunar Lake 以降専用の高速・省メモリ版": "Lunar Lake and later only: faster, less memory"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
  },
393
  "zh": {
394
  "Lunar Lake 以降専用の高速版(重み NF4、アテンションの入力と down_proj は INT8。INT4 版より高精度)": "仅限 Lunar Lake 及更新机型的高速版(NF4 权重,注意力输入与 down_proj 为 INT8;比 INT4 版更精确)",
@@ -779,6 +808,35 @@
779
  "ダウンロードを中止しますか?\nダウンロード済みの分は残り、次はその続きから取ります。": "要中止下载吗?\n已下载的部分会保留,下次从中断处继续。",
780
  "エラー": "错误",
781
  "onw の状態を読めません: {0}": "无法读取 onw 的状态:{0}",
782
- "Lunar Lake 以降専用の高速・省メモリ版": "仅限 Lunar Lake 及以后的快速省内存版"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
783
  }
784
- }
 
388
  "ダウンロードを中止しますか?\nダウンロード済みの分は残り、次はその続きから取ります。": "Stop the download?\nWhat has been downloaded stays; the next download continues from there.",
389
  "エラー": "Error",
390
  "onw の状態を読めません: {0}": "Could not read onw's state: {0}",
391
+ "Lunar Lake 以降専用の高速・省メモリ版": "Lunar Lake and later only: faster, less memory",
392
+ "ベンチマーク": "Benchmark",
393
+ "このサーバーで、プロンプトの読み込み(プリフィル)と回答の生成(デコード)の速さを測ります。": "Measures how fast this server reads prompts (prefill) and writes answers (decode).",
394
+ "プロンプトの長さ:": "Prompt length:",
395
+ "生成するトークン数": "Tokens to generate",
396
+ "回数": "Runs",
397
+ "ベンチマークを実行": "Run the benchmark",
398
+ "結果をコピー(Markdown)": "Copy the results (Markdown)",
399
+ "プリフィル": "Prefill",
400
+ "プリフィル速度": "Prefill speed",
401
+ "生成": "Generated",
402
+ "プリフィル=プロンプトを読み終えるまで(最初のトークンが出るまで)の時間と、1秒あたりに読んだトークン数。生成=そのあと回答を書く速さ(デコード)。温度 0、思考オフ。毎回、文頭に違う一文を入れて、前の会話を再利用しないようにしています。回数を増やすと、いちばん速かった回を表示します(最初に短い慣らし運転を1回します)。": "Prefill = the time until the prompt is read (until the first token) and the tokens read a second. Decode = how fast the answer is written after that. Temperature 0, thinking off. Every prompt starts with a different sentence, so nothing of an earlier one is reused. With more runs the fastest one is shown (one short warm-up run comes first).",
403
+ "上の文章の要点を、できるだけ詳しく説明してください。": "Explain the main points of the text above in as much detail as you can.",
404
+ "ベンチマーク {0}({1})": "Benchmark {0} ({1})",
405
+ "{0}トークン": "{0} tokens",
406
+ "慣らし運転…": "Warming up…",
407
+ "コンテキスト長({0})を超えるので飛ばしました": "Skipped: longer than the context ({0})",
408
+ "{0}({1}/{2}回目)…": "{0} (run {1} of {2})…",
409
+ "アプリ": "App",
410
+ "CPU": "CPU",
411
+ "OS": "OS",
412
+ "条件": "Settings",
413
+ "温度 0・思考オフ・生成 {0} トークン・{1} 回の最良値": "temperature 0, thinking off, {0} tokens generated, best of {1}",
414
+ "測定日": "Date",
415
+ "{0} ベンチマーク": "{0} benchmark",
416
+ "先読み(MTP・ドラフト)": "Look-ahead (MTP / draft)",
417
+ "プリフィル {0} tok/s": "Prefill {0} tok/s",
418
+ "生成 {0} tok/s": "Decode {0} tok/s",
419
+ "直前の回答:プロンプト {0} トークン(新しく読んだ分 {1})を {2} 秒で読み、{3} トークンを生成": "Last answer: read a prompt of {0} tokens ({1} new) in {2} s, generated {3} tokens",
420
+ "ベンチマークを開く": "Open the benchmark"
421
  },
422
  "zh": {
423
  "Lunar Lake 以降専用の高速版(重み NF4、アテンションの入力と down_proj は INT8。INT4 版より高精度)": "仅限 Lunar Lake 及更新机型的高速版(NF4 权重,注意力输入与 down_proj 为 INT8;比 INT4 版更精确)",
 
808
  "ダウンロードを中止しますか?\nダウンロード済みの分は残り、次はその続きから取ります。": "要中止下载吗?\n已下载的部分会保留,下次从中断处继续。",
809
  "エラー": "错误",
810
  "onw の状態を読めません: {0}": "无法读取 onw 的状态:{0}",
811
+ "Lunar Lake 以降専用の高速・省メモリ版": "仅限 Lunar Lake 及以后的快速省内存版",
812
+ "ベンチマーク": "基准测试",
813
+ "このサーバーで、プロンプトの読み込み(プリフィル)と回答の生成(デコード)の速さを測ります。": "测量此服务器读取提示词(预填充)和生成回答(解码)的速度。",
814
+ "プロンプトの長さ:": "提示词长度:",
815
+ "生成するトークン数": "生成的令牌数",
816
+ "回数": "次数",
817
+ "ベンチマークを実行": "运行基准测试",
818
+ "結果をコピー(Markdown)": "复制结果(Markdown)",
819
+ "プリフィル": "预填充",
820
+ "プリフィル速度": "预填充速度",
821
+ "生成": "生成",
822
+ "プリフィル=プロンプトを読み終えるまで(最初のトークンが出るまで)の時間と、1秒あたりに読んだトークン数。生成=そのあと回答を書く速さ(デコード)。温度 0、思考オフ。毎回、文頭に違う一文を入れて、前の会話を再利用しないようにしています。回数を増やすと、いちばん速かった回を表示します(最初に短い慣らし運転を1回します)。": "预填充 = 读完提示词(输出��一个令牌之前)的时间及每秒读取的令牌数。解码 = 之后写出回答的速度。温度 0,关闭思考。每次都以不同的句子开头,不会复用之前的对话。增加次数时显示最快的一次(开始前先进行一次简短的预热)。",
823
+ "上の文章の要点を、できるだけ詳しく説明してください。": "请尽可能详细地说明上文的要点。",
824
+ "ベンチマーク {0}({1})": "基准测试 {0}({1})",
825
+ "{0}トークン": "{0} 个令牌",
826
+ "慣らし運転…": "预热中…",
827
+ "コンテキスト長({0})を超えるので飛ばしました": "已跳过:超过上下文长度({0})",
828
+ "{0}({1}/{2}回目)…": "{0}(第 {1}/{2} 次)…",
829
+ "アプリ": "应用",
830
+ "CPU": "CPU",
831
+ "OS": "OS",
832
+ "条件": "条件",
833
+ "温度 0・思考オフ・生成 {0} トークン・{1} 回の最良値": "温度 0、关闭思考、生成 {0} 个令牌、{1} 次中的最佳值",
834
+ "測定日": "测量日期",
835
+ "{0} ベンチマーク": "{0} 基准测试",
836
+ "先読み(MTP・ドラフト)": "预读(MTP / 草稿模型)",
837
+ "プリフィル {0} tok/s": "预填充 {0} tok/s",
838
+ "生成 {0} tok/s": "生成 {0} tok/s",
839
+ "直前の回答:プロンプト {0} トークン(新しく読んだ分 {1})を {2} 秒で読み、{3} トークンを生成": "上一个回答:用 {2} 秒读取了 {0} 个令牌的提示词(新读取 {1} 个),生成了 {3} 个令牌",
840
+ "ベンチマークを開く": "打开基准测试"
841
+ }
842
  }
 
onw/manager.py CHANGED
@@ -694,8 +694,8 @@ class Manager:
694
  elif what == "folder":
695
  p = app.installed(cfg).get(key) if key else None
696
  app.open_path(p or app.models_dir(cfg))
697
- elif what in ("chat", "check"):
698
- webbrowser.open(f"http://127.0.0.1:{app.server_port(cfg)}/{what}")
699
  return {}
700
 
701
 
 
694
  elif what == "folder":
695
  p = app.installed(cfg).get(key) if key else None
696
  app.open_path(p or app.models_dir(cfg))
697
+ elif what in ("chat", "check", "bench"): # [bench] ("check": the old name of the benchmark page)
698
+ webbrowser.open(f"http://127.0.0.1:{app.server_port(cfg)}/{'chat' if what == 'chat' else 'bench'}")
699
  return {}
700
 
701
 
onw/server.py CHANGED
@@ -1,900 +1,975 @@
1
- """OpenAI-compatible API server for an onw model on the Intel NPU (point other apps at http://HOST:PORT/v1).
2
-
3
- GET / what this server is: model, OpenAI base URL, endpoints (JSON)
4
- GET /v1/models the loaded model with its limits: context_length (= max_model_len, max_context_length,
5
- meta.n_ctx) and max_output_tokens (what a request without max_tokens gets), "loaded":
6
- true; then the app's other models ("loaded": false). A request whose "model" names one
7
- of them loads it first (the running one is closed: one model in memory at a time); a
8
- name this server does not know is answered by the loaded model. ONW_API_SWITCH=0 (the
9
- app's setting off): only the loaded model is listed / used
10
- GET /v1/models/{id}
11
- POST /v1/chat/completions (stream true/false; text and image_url parts: data: URLs or http(s)); responses
12
- also carry "onw_stats" (prefill / decode speed, reused tokens) - extra field;
13
- "usage" (token counts) in a whole answer and on a stream's last chunk
14
- GET /health 200 when the model is loaded
15
- GET /chat browser chat UI for trying the model
16
- GET /check verification page: speed, image, multi-turn reuse
17
- GET /api/info model and device details
18
-
19
- usage: onw serve MODEL [--port 8000] [--host 0.0.0.0] [--api-key KEY] [--device NPU|CPU] [--open]
20
- CORS is open, so browser-based clients work too; with --api-key every /v1 request needs "Authorization: Bearer KEY"
21
- (except from this PC's own programs and pages: the chat and check pages send no key).
22
- Thinking mode: "chat_template_kwargs": {"enable_thinking": true}, or "reasoning_effort": "low" | "medium" | "high"
23
- (gpt-oss uses the level; "none" / "minimal" turn thinking off). Whatever the model thought comes back as
24
- message.reasoning_content (delta.reasoning_content when streaming; also for models that think with thinking off, as vLLM's
25
- reasoning parsers do), the answer as content, and the thinking's token count as OpenAI's
26
- usage.completion_tokens_details.reasoning_tokens.
27
- Tool calls: "tools" (OpenAI format) -> message.tool_calls / finish_reason "tool_calls"; results as role "tool".
28
- tool_choice "none" hides the tools; a named function narrows them to that one ("required" cannot be enforced).
29
- response_format json_object / json_schema is best effort (an instruction is added; the output is not constrained).
30
- Not supported: n > 1 (400), logprobs (null).
31
- One NPU pipeline serves requests one at a time (a lock serializes them).
32
- """
33
- import argparse, asyncio, base64, io, ipaddress, json, os, re, socket, threading, time, uuid, urllib.parse, urllib.request
34
-
35
- import uvicorn
36
- from fastapi import FastAPI, HTTPException, Request
37
- from fastapi.exceptions import RequestValidationError
38
- from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
39
- from PIL import Image
40
- from onw import __version__, i18n
41
- from onw import tools as tool_calls
42
-
43
- MODEL_ID = "npu-model" # set from the engine directory in main()
44
- app = FastAPI(title="LLM on Intel NPU")
45
- pipe = None # onw.chat.ChatEngine, created in main()
46
- info = {}
47
- lock = threading.Lock() # one generation at a time on the NPU
48
- WEB = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
49
- API_KEY = None # --api-key: required as a Bearer token on /v1
50
- API_SAMPLING = None # sampling for requests that send none (ONW_API_SAMPLING: "recommended" or a JSON of values)
51
- TOO_LONG = re.compile(r"the prompt is (\d+) tokens; the context holds (\d+)") # (ChatEngine.stream_chat's ValueError)
52
-
53
-
54
- def oai_error(status, message, type_="invalid_request_error", code=None):
55
- """An error in OpenAI's shape: {"error": {"message", "type", "code"}} (a prompt over the context: code
56
- "context_length_exceeded", as OpenAI's)."""
57
- code = code or ("context_length_exceeded" if TOO_LONG.match(message) else None)
58
- return JSONResponse({"error": {"message": message, "type": type_, "param": None, "code": code}}, status_code=status)
59
-
60
-
61
- def anthropic_message(message):
62
- """Anthropic's wording for a prompt over the context ("prompt is too long: N tokens > M maximum" - Claude Code
63
- compacts the conversation on it); other messages as they are."""
64
- m = TOO_LONG.match(message)
65
- return f"prompt is too long: {m[1]} tokens > {m[2]} maximum" if m else message
66
-
67
-
68
- def api_error(request, status, message, type_="invalid_request_error", code=None):
69
- """oai_error, or on the Anthropic Messages API (/v1/messages...) Anthropic's shape:
70
- {"type": "error", "error": {"type", "message"}}."""
71
- if request.url.path.startswith("/v1/messages"):
72
- typ = {401: "authentication_error", 403: "permission_error", 404: "not_found_error",
73
- 413: "request_too_large"}.get(status, "api_error" if status >= 500 else "invalid_request_error")
74
- return JSONResponse({"type": "error", "error": {"type": typ, "message": anthropic_message(message)}},
75
- status_code=status)
76
- return oai_error(status, message, type_, code)
77
-
78
-
79
- @app.exception_handler(HTTPException)
80
- async def _http_error(request, e):
81
- return api_error(request, e.status_code, str(e.detail),
82
- "invalid_request_error" if e.status_code < 500 else "server_error")
83
-
84
-
85
- @app.exception_handler(RequestValidationError)
86
- async def _validation_error(request, e):
87
- return api_error(request, 400, str(e))
88
-
89
-
90
- @app.exception_handler(Exception)
91
- async def _server_error(request, e):
92
- return api_error(request, 500, f"{type(e).__name__}: {e}", "server_error")
93
-
94
-
95
- CORS = [(b"access-control-allow-origin", b"*"), (b"access-control-allow-headers", b"*"),
96
- (b"access-control-allow-methods", b"GET, POST, OPTIONS")]
97
-
98
-
99
- class CorsAndKey:
100
- """Open CORS (browser-based OpenAI clients) and the optional API key on /v1 (not asked of this PC's own programs and
101
- pages - the chat and check pages send none; the key keeps the LAN out). A plain ASGI middleware, not
102
- @app.middleware("http"): behind that one (Starlette's BaseHTTPMiddleware) request.is_disconnected() never turns
103
- true, and a client that left a whole (non-streamed) answer kept the NPU busy to max_tokens."""
104
- def __init__(self, inner):
105
- self.inner = inner
106
-
107
- async def __call__(self, scope, receive, send):
108
- if scope["type"] != "http":
109
- return await self.inner(scope, receive, send)
110
- request = Request(scope) # (headers and client only: the body stays with the app)
111
- path = scope["path"]
112
- if path.startswith("/api/chats"): # saved conversations: this PC's own chat page only
113
- why = _chats_refused(request)
114
- if why:
115
- return await JSONResponse({"error": why}, status_code=403)(scope, receive, send) # (no CORS)
116
- return await self.inner(scope, receive, send)
117
-
118
- async def with_cors(msg):
119
- if msg["type"] == "http.response.start":
120
- msg = {**msg, "headers": [h for h in msg.get("headers", []) if h[0].lower() not in dict(CORS)] + CORS}
121
- await send(msg)
122
- if scope["method"] == "OPTIONS":
123
- return await JSONResponse({})(scope, receive, with_cors)
124
- if API_KEY and path.startswith("/v1") and not _local(request) and \
125
- request.headers.get("authorization", "") != f"Bearer {API_KEY}" and \
126
- request.headers.get("x-api-key", "") != API_KEY: # (Anthropic clients send x-api-key)
127
- return await api_error(request, 401, "invalid API key", code="invalid_api_key")(scope, receive, with_cors)
128
- await self.inner(scope, receive, with_cors)
129
-
130
-
131
- app.add_middleware(CorsAndKey)
132
-
133
-
134
- # ---------------------------------------------------------------- saved conversations (the chat page's history)
135
- LOCAL_HOSTS = ("localhost", "127.0.0.1", "[::1]", "::1")
136
- CHAT_ID = __import__("re").compile(r"^[a-z0-9-]{8,48}$")
137
- CHAT_MAX = 32 << 20 # bytes per conversation (images are kept small by the page)
138
-
139
-
140
- def _chats_refused(request):
141
- """Why a request for the saved conversations is refused, or None: only from this PC (also when the server listens on
142
- the LAN), only under a localhost name (a rebound DNS name of some web site would otherwise pass as same-origin) and
143
- only from the chat page itself (no cross-site reads or writes: these responses carry no CORS headers)."""
144
- client = request.client.host if request.client else ""
145
- try:
146
- if not ipaddress.ip_address(client).is_loopback:
147
- return "only from this PC"
148
- except ValueError:
149
- return "only from this PC"
150
- host = urllib.parse.urlsplit("//" + request.headers.get("host", "")).hostname or ""
151
- if host not in LOCAL_HOSTS:
152
- return "only under localhost"
153
- origin = request.headers.get("origin")
154
- if origin and (urllib.parse.urlsplit(origin).hostname or "") not in LOCAL_HOSTS:
155
- return "not from another site"
156
- return None
157
-
158
-
159
- def _local(request):
160
- """True for a request from this PC itself as _chats_refused judges it (this PC's address, a localhost name, no other
161
- site's page) and not one a proxy forwards (with X-Forwarded-For from a proxy on this PC, uvicorn already puts the
162
- real client's address in request.client; a bare "Forwarded" it does not read)."""
163
- return _chats_refused(request) is None and "forwarded" not in request.headers and \
164
- "x-forwarded-for" not in request.headers
165
-
166
-
167
- def chats_dir():
168
- from onw import app as _app
169
- d = os.path.join(_app.config_dir(), "chats")
170
- os.makedirs(d, exist_ok=True)
171
- return d
172
-
173
-
174
- def _chat_path(cid):
175
- if not CHAT_ID.match(cid or ""):
176
- raise HTTPException(400, "bad conversation id")
177
- return os.path.join(chats_dir(), cid + ".json")
178
-
179
-
180
- @app.get("/api/chats")
181
- def chats_list():
182
- out = []
183
- for f in os.listdir(chats_dir()):
184
- if not f.endswith(".json"):
185
- continue
186
- try:
187
- c = json.load(open(os.path.join(chats_dir(), f), encoding="utf8"))
188
- out.append({k: c.get(k) for k in ("id", "title", "model", "created", "updated")} | {"n": len(c.get("messages", []))})
189
- except (OSError, ValueError):
190
- continue
191
- return sorted(out, key=lambda c: c.get("updated") or 0, reverse=True)
192
-
193
-
194
- @app.get("/api/chats/{cid}")
195
- def chat_get(cid: str):
196
- try:
197
- return json.load(open(_chat_path(cid), encoding="utf8"))
198
- except FileNotFoundError:
199
- raise HTTPException(404, "no such conversation")
200
-
201
-
202
- @app.put("/api/chats/{cid}")
203
- async def chat_put(cid: str, request: Request):
204
- p = _chat_path(cid)
205
- raw = await request.body()
206
- if len(raw) > CHAT_MAX:
207
- raise HTTPException(413, "conversation too large")
208
- try:
209
- c = json.loads(raw)
210
- assert isinstance(c, dict) and isinstance(c.get("messages"), list)
211
- except (ValueError, AssertionError):
212
- raise HTTPException(400, "not a conversation")
213
- c["id"] = cid
214
- with open(p + ".tmp", "w", encoding="utf8") as f:
215
- json.dump(c, f, ensure_ascii=False)
216
- os.replace(p + ".tmp", p)
217
- return {"ok": True}
218
-
219
-
220
- @app.delete("/api/chats/{cid}")
221
- def chat_delete(cid: str):
222
- try:
223
- os.remove(_chat_path(cid))
224
- except FileNotFoundError:
225
- pass
226
- return {"ok": True}
227
-
228
-
229
- @app.delete("/api/chats")
230
- def chats_delete_all():
231
- n = 0
232
- for f in os.listdir(chats_dir()):
233
- if f.endswith(".json"):
234
- try:
235
- os.remove(os.path.join(chats_dir(), f))
236
- n += 1
237
- except OSError:
238
- pass
239
- return {"ok": True, "deleted": n}
240
-
241
-
242
- def start_generation(msgs, kw):
243
- """Run stream_chat in a worker thread; returns (asyncio queue of deltas/stats/exceptions, None at the end; stop
244
- event). Called on the event loop: the worker hands its items over to it, so no thread waits per queued request."""
245
- loop, q, stop = asyncio.get_running_loop(), asyncio.Queue(), threading.Event()
246
-
247
- def put(x):
248
- try:
249
- loop.call_soon_threadsafe(q.put_nowait, x)
250
- except RuntimeError: # the event loop is gone (the server is stopping)
251
- stop.set()
252
-
253
- def work():
254
- try:
255
- with lock:
256
- if stop.is_set(): # the client left while this request waited for the NPU
257
- return
258
- set_priority(True)
259
- try:
260
- for d in pipe.stream_chat(msgs, should_stop=stop.is_set, split_reasoning=True, **kw):
261
- put(d)
262
- finally:
263
- set_priority(False)
264
- except Exception as e: # surfaced to the client
265
- put(e)
266
- finally:
267
- put(None)
268
- threading.Thread(target=work, daemon=True).start()
269
- return q, stop
270
-
271
-
272
- def reader(q, stop, request=None):
273
- """-> async function returning start_generation's next item. With request (a whole response, not a stream): a
274
- client that leaves meanwhile stops the generation (checked about once a second; a stream stops when it closes)."""
275
- last = [time.monotonic()]
276
-
277
- async def next_item():
278
- while request is not None and not stop.is_set():
279
- wait = last[0] + 1.0 - time.monotonic()
280
- if wait <= 0:
281
- last[0] = time.monotonic()
282
- if await request.is_disconnected():
283
- stop.set()
284
- continue
285
- try:
286
- return await asyncio.wait_for(q.get(), wait)
287
- except asyncio.TimeoutError:
288
- pass
289
- return await q.get()
290
- return next_item
291
-
292
-
293
- MAX_IMAGE = 20 << 20 # bytes
294
-
295
-
296
- def _public_host(url):
297
- """True when every address the URL's host resolves to is a public one: the server does not fetch from the local
298
- network on a client's behalf (ONW_ALLOW_LOCAL_IMAGES=1 allows it)."""
299
- if os.environ.get("ONW_ALLOW_LOCAL_IMAGES") == "1":
300
- return True
301
- host = urllib.parse.urlsplit(url).hostname
302
- try:
303
- addrs = {a[4][0] for a in socket.getaddrinfo(host, None)}
304
- except (socket.gaierror, UnicodeError, TypeError):
305
- return False
306
- return bool(addrs) and all(ipaddress.ip_address(a.split("%")[0]).is_global for a in addrs)
307
-
308
-
309
- def load_image(url):
310
- try:
311
- if url.startswith("data:"):
312
- data = base64.b64decode(url.split(",", 1)[1])
313
- elif url.startswith(("http://", "https://")):
314
- if not _public_host(url):
315
- raise HTTPException(400, "image_url: only public http(s) addresses are fetched (send a data: URL instead)")
316
- req = urllib.request.Request(url, headers={"User-Agent": f"onw/{__version__}"})
317
- with urllib.request.urlopen(req, timeout=20) as r:
318
- data = r.read(MAX_IMAGE + 1)
319
- if len(data) > MAX_IMAGE:
320
- raise HTTPException(400, "image_url: the image is larger than 20 MB")
321
- else:
322
- raise HTTPException(400, "image_url must be a data: URL or http(s) URL")
323
- return Image.open(io.BytesIO(data)).convert("RGB")
324
- except HTTPException:
325
- raise
326
- except Exception as e: # bad base64, not an image, unreachable URL
327
- raise HTTPException(400, f"image_url: {type(e).__name__}: {e}")
328
-
329
-
330
- def to_hf_messages(messages):
331
- """OpenAI chat messages -> HF chat format with PIL images."""
332
- out = []
333
- for m in tool_calls.history(messages):
334
- keep = {k: m[k] for k in ("tool_calls", "tool_call_id", "name", "reasoning_content") if m.get(k) is not None}
335
- c = m.get("content")
336
- if c is None or isinstance(c, str):
337
- out.append({"role": m["role"], "content": c or "", **keep})
338
- continue
339
- parts = []
340
- for p in c:
341
- if p.get("type") == "text":
342
- parts.append({"type": "text", "text": p.get("text") or ""})
343
- elif p.get("type") == "image_url":
344
- iu = p.get("image_url") or {}
345
- url = iu.get("url") if isinstance(iu, dict) else iu
346
- if not isinstance(url, str):
347
- raise HTTPException(400, "image_url part without a url")
348
- parts.append({"type": "image", "image": load_image(url)})
349
- else:
350
- raise HTTPException(400, f"unsupported content part type: {p.get('type')}")
351
- out.append({"role": m["role"], "content": parts, **keep})
352
- return out
353
-
354
-
355
- def _num(body, key, default, cast=float):
356
- v = body.get(key)
357
- if v is None:
358
- return default
359
- try:
360
- return cast(v)
361
- except (TypeError, ValueError):
362
- raise HTTPException(400, f"{key} must be a number")
363
-
364
-
365
- LEVELS = ("off", "low", "medium", "high") # the thinking levels of the window and the chat page (reasoning_effort)
366
- _PREFS = [None, None] # (config mtime, this model's defaults)
367
-
368
-
369
- def model_defaults():
370
- """This model's defaults from the onw window (config model_prefs, read again when the file changes - no restart):
371
- {"think": level, "sampling": "greedy" | "recommended" | "custom", values...}; {} when none were saved. Settings
372
- of earlier versions (ONW_THINK_DEFAULT, ONW_API_SAMPLING) apply where these say nothing."""
373
- key = os.environ.get("ONW_MODEL_KEY")
374
- if not key:
375
- return {}
376
- try:
377
- from onw import app as _app
378
- p = os.path.join(_app.config_dir(), "config.json")
379
- mt = os.path.getmtime(p)
380
- if _PREFS[0] != mt:
381
- _PREFS[:] = [mt, (_app.load_config().get("model_prefs") or {}).get(key) or {}]
382
- return _PREFS[1]
383
- except (OSError, ValueError):
384
- return {}
385
-
386
-
387
- def default_think():
388
- """Thinking for a request that says nothing about it: the model's saved level, else the old switch."""
389
- lvl = model_defaults().get("think")
390
- if lvl == "off":
391
- return False
392
- if lvl in LEVELS:
393
- return lvl
394
- return True if os.environ.get("ONW_THINK_DEFAULT") == "1" else None
395
-
396
-
397
- def default_sampling():
398
- """Sampling for a request that sends none: the model's saved choice, else the old setting (API_SAMPLING)."""
399
- d = model_defaults()
400
- mode = d.get("sampling")
401
- if mode == "greedy":
402
- return None
403
- if mode == "recommended":
404
- return {k: v for k, v in (info.get("sampling") or {}).items() if k != "source"} or None
405
- if mode == "custom":
406
- return {k: d[k] for k in ("temperature", "top_p", "top_k", "presence_penalty") if k in d}
407
- return API_SAMPLING
408
-
409
-
410
- def gen_args(body):
411
- """OpenAI request fields -> ChatEngine.stream_chat arguments."""
412
- if _num(body, "n", 1, int) != 1:
413
- raise HTTPException(400, "n > 1 is not supported (one NPU pipeline)")
414
- stop = body.get("stop") or []
415
- mx = body.get("max_completion_tokens", body.get("max_tokens"))
416
- # a request without max_tokens: the setting (ONW_MAX_TOKENS; 0 = up to the end of the context - the engine stops
417
- # there anyway); agents send none and write whole files in one answer
418
- if mx is None:
419
- mx = int(os.environ.get("ONW_MAX_TOKENS", "8192") or 0) or (1 << 30)
420
- else:
421
- mx = _num({"max_tokens": mx}, "max_tokens", 512, int)
422
- if mx < 1:
423
- raise HTTPException(400, "max_tokens must be at least 1")
424
- think = (body.get("chat_template_kwargs") or {}).get("enable_thinking")
425
- effort = body.get("reasoning_effort")
426
- if isinstance(effort, str): # a level: low / medium / high (gpt-oss, llm-jp: trained;
427
- think = False if effort in ("none", "minimal", "off") else effort # others: a thinking budget)
428
- if think is None: # nothing said: the window's default for this model
429
- think = default_think()
430
- tools, choice = body.get("tools") or None, body.get("tool_choice")
431
- if choice == "none":
432
- tools = None
433
- elif isinstance(choice, dict) and tools: # a named function: offer just that one
434
- name = (choice.get("function") or {}).get("name")
435
- tools = [t for t in tools if (t.get("function") or {}).get("name") == name] or tools
436
- # what the request leaves out: greedy, or the settings' API defaults (API_SAMPLING; thinking has its own set)
437
- dflt = dict(default_sampling() or {})
438
- if think and isinstance(dflt.get("think"), dict):
439
- dflt.update(dflt["think"])
440
- return dict(max_new_tokens=mx, temperature=_num(body, "temperature", dflt.get("temperature", 0.0)),
441
- top_p=_num(body, "top_p", dflt.get("top_p", 1.0)),
442
- stop=[stop] if isinstance(stop, str) else list(stop), seed=body.get("seed"), think=think,
443
- top_k=_num(body, "top_k", dflt.get("top_k", 0), int), repetition_penalty=_num(body, "repetition_penalty", 1.0),
444
- presence_penalty=_num(body, "presence_penalty", dflt.get("presence_penalty", 0.0)),
445
- frequency_penalty=_num(body, "frequency_penalty", 0.0), tools=tools)
446
-
447
-
448
- def with_format(msgs, body):
449
- """response_format json_object / json_schema: best effort - an instruction as a system message (the output is not
450
- constrained)."""
451
- rf = body.get("response_format") or {}
452
- kind = rf.get("type") if isinstance(rf, dict) else None
453
- if kind not in ("json_object", "json_schema"):
454
- return msgs
455
- note = "Respond with a single JSON object only, no other text."
456
- if kind == "json_schema":
457
- note += " It must follow this JSON schema: " + json.dumps((rf.get("json_schema") or {}).get("schema") or {},
458
- ensure_ascii=False)
459
- if msgs and msgs[0]["role"] == "system" and isinstance(msgs[0]["content"], str):
460
- return [dict(msgs[0], content=msgs[0]["content"] + "\n\n" + note)] + msgs[1:]
461
- return [{"role": "system", "content": note}] + msgs
462
-
463
-
464
- @app.get("/")
465
- def index(request: Request):
466
- base = str(request.base_url).rstrip("/")
467
- return {"object": "onw.server", "name": "onw - 俺のNPUがこんなに動くわけない", "version": __version__, "model": MODEL_ID,
468
- "openai_base_url": base + "/v1", "api_key_required": bool(API_KEY),
469
- "endpoints": {"models": "/v1/models", "chat_completions": "/v1/chat/completions", "health": "/health",
470
- "chat_ui": "/chat", "check_page": "/check", "info": "/api/info"},
471
- "context": info.get("max_context"), "max_output_tokens": max_output_tokens(), "vision": info.get("vision")}
472
-
473
-
474
- @app.get("/health")
475
- def health():
476
- if SWITCHING.is_set() or pipe is None: # a model switch under way
477
- return JSONResponse({"status": "loading", "model": MODEL_ID}, status_code=503)
478
- return {"status": "ok", "model": MODEL_ID}
479
-
480
-
481
- @app.get("/chat")
482
- def chat_ui():
483
- return HTMLResponse(i18n.page(os.path.join(WEB, "index.html")))
484
-
485
-
486
- @app.get("/check")
487
- def check_page():
488
- return HTMLResponse(i18n.page(os.path.join(WEB, "check.html")))
489
-
490
-
491
- @app.get("/api/info")
492
- def api_info():
493
- # the window's defaults for this model, read now (they change without a restart)
494
- d, lvl = model_defaults(), default_think()
495
- samp = default_sampling()
496
- mode = d.get("sampling") or ("custom" if API_SAMPLING and os.environ.get("ONW_API_SAMPLING") != "recommended"
497
- else "recommended" if API_SAMPLING else "greedy")
498
- return {**info, "api_sampling": samp, "api_sampling_mode": mode,
499
- "think_default": "off" if lvl is False else ("medium" if lvl is True else lvl)}
500
-
501
-
502
- STARTED = int(time.time())
503
-
504
-
505
- def max_output_tokens():
506
- """The answer length a request without max_tokens gets (the setting; 0 = up to the context's end)."""
507
- ctx = info.get("max_context") or 0
508
- mx = int(os.environ.get("ONW_MAX_TOKENS", "8192") or 0)
509
- return min(mx, ctx) if mx and ctx else (mx or ctx or None)
510
-
511
-
512
- def _model_card(mid=None):
513
- # the limits under the names clients read them from: context_length (OpenRouter / Open WebUI), max_model_len
514
- # (vLLM), max_context_length (LM Studio), meta.n_ctx (llama.cpp's server). Another installed model (mid): the
515
- # context setting it would load with (at most its own maximum, which shows once it is loaded).
516
- loaded = mid is None or mid == MODEL_ID
517
- ctx = info.get("max_context") if loaded else SERVE.get("context")
518
- mx = int(os.environ.get("ONW_MAX_TOKENS", "8192") or 0)
519
- out = max_output_tokens() if loaded else (min(mx, ctx) if mx and ctx else (mx or ctx or None))
520
- return {"id": MODEL_ID if loaded else mid, "object": "model", "created": STARTED, "owned_by": "local-npu",
521
- "loaded": loaded, "context_length": ctx, "max_model_len": ctx, "max_context_length": ctx,
522
- "max_output_tokens": out, "meta": {"n_ctx": ctx}}
523
-
524
-
525
- @app.get("/v1/models")
526
- def models():
527
- """The loaded model first, then (when switching is allowed) every other model the app has: a request naming one
528
- loads it."""
529
- others = [m for m in installed_models() if m != MODEL_ID] if switch_allowed() else []
530
- return {"object": "list", "data": [_model_card()] + [_model_card(m) for m in others]}
531
-
532
-
533
- @app.get("/v1/models/{model_id:path}")
534
- def model(model_id: str):
535
- if model_id == MODEL_ID:
536
- return _model_card()
537
- hit = next((m for m, (k, _) in installed_models().items() if model_id in (m, k)), None) if switch_allowed() else None
538
- if hit is None:
539
- raise HTTPException(404, f"model {model_id!r} not found (this server runs {MODEL_ID!r})")
540
- return _model_card(hit)
541
-
542
-
543
- def finalize(text, tools, finish):
544
- """The answer's end: tool calls parsed out of the text (OpenAI tool_calls), finish_reason "tool_calls" when there
545
- are any. -> (text, calls, finish_reason)."""
546
- if not tools:
547
- return text, [], finish
548
- text, calls = tool_calls.parse(text, tools)
549
- return text, calls, ("tool_calls" if calls else finish)
550
-
551
-
552
- def usage_of(st):
553
- return {"prompt_tokens": st["prompt_tokens"], "completion_tokens": st["completion_tokens"],
554
- "total_tokens": st["prompt_tokens"] + st["completion_tokens"],
555
- "prompt_tokens_details": {"cached_tokens": st.get("cached_tokens", 0)},
556
- # (OpenAI's field: how many of the completion tokens were thinking)
557
- "completion_tokens_details": {"reasoning_tokens": st.get("reasoning_tokens", 0)}}
558
-
559
-
560
- def stats_of(st):
561
- return {k: (round(v, 2) if isinstance(v, float) else v) for k, v in st.items() if k != "finish_reason"}
562
-
563
-
564
- def worker_error(e):
565
- """A generation error -> (HTTP status, OpenAI error type): the request's fault (a prompt too long, an image for a
566
- text-only model) or the server's (the NPU, the driver)."""
567
- return (400, "invalid_request_error") if isinstance(e, (ValueError, KeyError, TypeError)) else (500, "server_error")
568
-
569
-
570
- @app.post("/v1/chat/completions")
571
- async def chat(request: Request):
572
- try:
573
- body = await request.json()
574
- except ValueError:
575
- raise HTTPException(400, "the request body is not valid JSON")
576
- if not isinstance(body, dict) or not isinstance(body.get("messages"), list):
577
- raise HTTPException(400, "messages (a list) is required")
578
- await ensure_model(body.get("model")) # another installed model named: load it
579
- kw = gen_args(body)
580
- loop = asyncio.get_running_loop()
581
- try: # images may be downloaded: not on the event loop
582
- msgs = await loop.run_in_executor(None, to_hf_messages, body["messages"])
583
- except (KeyError, TypeError, AttributeError, ValueError) as e:
584
- raise HTTPException(400, f"malformed messages: {e}")
585
- msgs = with_format(msgs, body)
586
- cid, created = f"chatcmpl-{uuid.uuid4().hex[:24]}", int(time.time())
587
- tools = kw["tools"]
588
- if body.get("stream"):
589
- def chunk(delta, finish=None, **extra):
590
- d = {"id": cid, "object": "chat.completion.chunk", "created": created, "model": MODEL_ID,
591
- "choices": [{"index": 0, "delta": delta, "logprobs": None, "finish_reason": finish}], **extra}
592
- return f"data: {json.dumps(d, ensure_ascii=False)}\n\n"
593
-
594
- async def events():
595
- full, sent = "", 0 # with tools: text is held back once a call may start
596
- # (started here, not before the response: one whose stream never began would generate on to max_tokens)
597
- q, stop = start_generation(msgs, kw)
598
- next_item = reader(q, stop)
599
- try:
600
- yield chunk({"role": "assistant", "content": ""})
601
- while True:
602
- d = await next_item()
603
- if d is None:
604
- break
605
- if isinstance(d, Exception):
606
- status, typ = worker_error(d)
607
- err = {"error": {"message": str(d), "type": typ, "param": None,
608
- "code": "context_length_exceeded" if TOO_LONG.match(str(d)) else None}}
609
- yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
610
- elif isinstance(d, dict) and "reasoning" in d:
611
- yield chunk({"reasoning_content": d["reasoning"]})
612
- elif isinstance(d, dict):
613
- rest, calls, finish = finalize(full, tools, d["finish_reason"])
614
- if tools and rest.startswith(full[:sent]) and rest[sent:].strip(): # text after the calls
615
- yield chunk({"content": rest[sent:]})
616
- for i, tc in enumerate(calls):
617
- yield chunk({"tool_calls": [dict(tc, index=i)]})
618
- # the token counts on the last chunk too (as llama.cpp's server does): clients that do not
619
- # send stream_options.include_usage still see them
620
- yield chunk({}, finish, usage=usage_of(d), onw_stats=stats_of(d))
621
- if (body.get("stream_options") or {}).get("include_usage"): # OpenAI: its own last chunk
622
- yield f"data: {json.dumps({'id': cid, 'object': 'chat.completion.chunk', 'created': created, 'model': MODEL_ID, 'choices': [], 'usage': usage_of(d)})}\n\n"
623
- elif tools:
624
- full += d
625
- upto = tool_calls.safe_upto(full)
626
- if upto > sent:
627
- yield chunk({"content": full[sent:upto]})
628
- sent = upto
629
- else:
630
- yield chunk({"content": d})
631
- yield "data: [DONE]\n\n"
632
- finally: # client disconnected or finished: stop the NPU loop so the lock is released
633
- stop.set()
634
- return StreamingResponse(events(), media_type="text/event-stream",
635
- headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
636
-
637
- parts, thought, st = [], [], None
638
- q, stop = start_generation(msgs, kw)
639
- next_item = reader(q, stop, request) # (a client that leaves stops the generation)
640
- try:
641
- while True:
642
- d = await next_item()
643
- if d is None:
644
- break
645
- if isinstance(d, Exception):
646
- status, typ = worker_error(d)
647
- return oai_error(status, str(d), typ)
648
- if isinstance(d, dict) and "reasoning" in d:
649
- thought.append(d["reasoning"])
650
- elif isinstance(d, dict):
651
- st = d
652
- else:
653
- parts.append(d)
654
- finally:
655
- stop.set()
656
- if st is None:
657
- return oai_error(500, "the generation ended without a result", "server_error")
658
- text, calls, finish = finalize("".join(parts), tools, st["finish_reason"])
659
- msg = {"role": "assistant", "content": (text.strip() or None) if tools else text}
660
- if thought:
661
- msg["reasoning_content"] = "".join(thought)
662
- if calls:
663
- msg["tool_calls"] = calls
664
- return JSONResponse({
665
- "id": cid, "object": "chat.completion", "created": created, "model": MODEL_ID,
666
- "choices": [{"index": 0, "message": msg, "logprobs": None, "finish_reason": finish}],
667
- "usage": usage_of(st),
668
- "onw_stats": stats_of(st),
669
- })
670
-
671
-
672
- def recommended_sampling(arch, engine_dir=None):
673
- """The chat page's starting sampling values ({temperature, top_p, top_k, presence_penalty?, think?, source}) or
674
- None (greedy): the model's own generation_config.json first (Gemma 4, Granite 4.2), else onw/sampling.json by
675
- model_type prefix. The API itself stays greedy unless asked."""
676
- keys = ("temperature", "top_p", "top_k")
677
- try:
678
- with open(os.path.join(engine_dir, "generation_config.json"), encoding="utf8") as f:
679
- g = json.load(f)
680
- if g.get("do_sample") and any(k in g for k in keys):
681
- return {"source": "model", "temperature": g.get("temperature", 1.0), "top_p": g.get("top_p", 1.0),
682
- "top_k": g.get("top_k", 0)}
683
- except (OSError, ValueError, TypeError):
684
- pass
685
- try:
686
- with open(os.path.join(WEB, "..", "sampling.json"), encoding="utf8") as f:
687
- table = {k: v for k, v in json.load(f).items() if not k.startswith("_")}
688
- except (OSError, ValueError):
689
- return None
690
- hit = max((k for k in table if arch.startswith(k)), key=len, default=None)
691
- if hit is None:
692
- return None
693
- return {k: v for k, v in table[hit].items() if k != "note"}
694
-
695
-
696
- from onw import compat # noqa: E402,F401 (/v1/messages and /v1/responses on this app)
697
-
698
-
699
- def set_priority(busy):
700
- """Windows: the server's priority class - normal while loading and waiting, above normal while it generates
701
- (busy). Windows puts a below-normal process on the low-power cores at low clocks (onw 1.4.5 and earlier started
702
- the server below normal: Lunar Lake, Qwen3.5-9B NF4 with MTP ~12 tok/s, 16 above normal); generating takes one or two
703
- cores, so the rest of the PC hardly notices. ONW_PRIORITY: the class while generating, normal | above (default) |
704
- off (leave it as started)."""
705
- want = os.environ.get("ONW_PRIORITY", "above")
706
- if os.name != "nt" or want not in ("normal", "above"):
707
- return
708
- import ctypes
709
- k32 = ctypes.WinDLL("kernel32")
710
- k32.GetCurrentProcess.restype = ctypes.c_void_p
711
- k32.SetPriorityClass.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
712
- k32.SetPriorityClass(k32.GetCurrentProcess(), 0x8000 if busy and want == "above" else 0x20)
713
-
714
-
715
- def load_model(path, name=None):
716
- """Load a model as the one this server runs: pipe, MODEL_ID, info and the sampling defaults. main() and a
717
- model switch (switch_model) both come here."""
718
- global pipe, MODEL_ID, API_SAMPLING
719
- from onw.chat import ChatEngine
720
- pipe = ChatEngine(path, SERVE["device"], context=SERVE["context"], pld=SERVE["pld"])
721
- MODEL_ID = name or os.path.basename(os.path.normpath(path))
722
- v = pipe.vision
723
- info.update(model=MODEL_ID, title=MODEL_ID, devices={"lm": SERVE["device"]}, fast=False, vision=bool(v),
724
- image_px=v.get("image_px") if v else None,
725
- think=pipe.think_style is not None, max_context=pipe.max_context,
726
- always_thinks=pipe.think_style == "harmony", # (gpt-oss, llm-jp: the thinking counts against max_tokens)
727
- note=f"文脈は約{pipe.max_context}トークン", context=pipe.max_context, pld=pipe.pld,
728
- mtp=getattr(pipe.model, "mtp", None) is not None)
729
- try:
730
- with open(os.path.join(path, "config.json"), encoding="utf8") as f:
731
- arch = json.load(f).get("model_type") or ""
732
- except (OSError, ValueError):
733
- arch = ""
734
- # greedy Gemma on the NPU can fall into repeating one word (fp16 on the NPU tips a near tie; the same happens with
735
- # any onw version): the pages suggest a repetition penalty, the API default stays 1.0 (OpenAI behaviour)
736
- info.update(arch=arch, rep_hint=1.2 if arch.startswith("gemma") else None, sampling=recommended_sampling(arch, path))
737
- want = os.environ.get("ONW_API_SAMPLING")
738
- API_SAMPLING = None
739
- if want == "recommended":
740
- API_SAMPLING = {k: v for k, v in (info["sampling"] or {}).items() if k != "source"} or None
741
- elif want:
742
- try:
743
- API_SAMPLING = json.loads(want)
744
- except ValueError:
745
- API_SAMPLING = None
746
- info.update(api_sampling=API_SAMPLING, api_sampling_mode="recommended" if want == "recommended" else
747
- "custom" if API_SAMPLING else "greedy")
748
- if API_SAMPLING:
749
- print(f"[api] requests without sampling values use {API_SAMPLING}", flush=True)
750
-
751
-
752
- # ---------------------------------------------------------------- model choice from the API
753
- # A request's "model" naming another model the app has (its id in /v1/models, or the app's key) unloads the running one
754
- # and loads that one first (one model in memory at a time), as Ollama and LM Studio do. A name this server does not
755
- # know - clients send their own defaults ("gpt-4o", ...) - is served by the loaded model, as before. The app's setting
756
- # "api_model_switch" (ONW_API_SWITCH=0) turns switching off.
757
- MODEL_KEY = None # the app's key of the loaded model (catalog repo id or folder name; ONW_MODEL_KEY)
758
- SERVE = {} # how main() loads models: device, context, pld, port
759
- SWITCHING = threading.Event() # set while a switch unloads / loads (/health answers 503: the window shows loading)
760
-
761
-
762
- def switch_allowed():
763
- return os.environ.get("ONW_API_SWITCH", "1") != "0"
764
-
765
-
766
- def installed_models():
767
- """{API id: (app key, path)} of every model the app finds in its folders (the ids of /v1/models)."""
768
- try:
769
- from onw import app as A
770
- found = A.installed(A.load_config())
771
- except Exception:
772
- return {}
773
- out = {}
774
- for key, path in found.items():
775
- if path and os.path.isdir(path):
776
- out.setdefault(key.split("/")[-1], (key, path))
777
- return out
778
-
779
-
780
- def resolve_model(name):
781
- """(app key, path) when a request's model names another installed model, else None (the loaded one serves)."""
782
- if not name or not switch_allowed():
783
- return None
784
- n = str(name).strip().lower()
785
- if n in ((MODEL_ID or "").lower(), (MODEL_KEY or "").lower()):
786
- return None
787
- for mid, (key, path) in installed_models().items():
788
- if n in (mid.lower(), key.lower()):
789
- return None if key == MODEL_KEY else (key, path)
790
- return None
791
-
792
-
793
- def switch_model(key, path):
794
- """Unload the running model and load `key` (called with `lock` held, so no generation runs). If the new one does
795
- not load, the old one is loaded again and the error goes to the client."""
796
- global pipe, MODEL_KEY
797
- import gc
798
- old_key = MODEL_KEY
799
- old_path = installed_models().get((MODEL_KEY or "").split("/")[-1], (None, None))[1]
800
- print(f"[api] switching model: {MODEL_ID} -> {key.split('/')[-1]}", flush=True)
801
- SWITCHING.set()
802
- t = time.time()
803
- try:
804
- old = pipe
805
- pipe = None
806
- close = getattr(getattr(old, "model", None), "close", None)
807
- if close:
808
- close()
809
- del old
810
- gc.collect()
811
- try:
812
- os.environ["ONW_MODEL_KEY"] = key
813
- load_model(path, key.split("/")[-1])
814
- MODEL_KEY = key
815
- except Exception:
816
- if old_path: # back to the model that ran
817
- os.environ["ONW_MODEL_KEY"] = old_key or ""
818
- load_model(old_path, (old_key or "").split("/")[-1] or None)
819
- raise
820
- print(f"[api] {MODEL_ID} loaded in {time.time() - t:.1f}s", flush=True)
821
- _record_model(key, path)
822
- finally:
823
- SWITCHING.clear()
824
-
825
-
826
- async def ensure_model(name):
827
- """Before a request's arguments are read (the sampling defaults and thinking style are the model's): the model it
828
- names loaded, after any generation that runs now (the same lock). A failed load: 500 with why."""
829
- if resolve_model(name) is None:
830
- return
831
-
832
- def work():
833
- with lock:
834
- want = resolve_model(name) # (another request may have switched to it meanwhile)
835
- if want:
836
- switch_model(*want)
837
- try:
838
- await asyncio.get_running_loop().run_in_executor(None, work)
839
- except Exception as e:
840
- raise HTTPException(500, f"model {name!r} could not be loaded: {type(e).__name__}: {e}")
841
-
842
-
843
- def _record_model(key, path):
844
- """The window and the next start follow the switch: the server record (pid file) and the chosen model."""
845
- try:
846
- from onw import app as A
847
- p = A.PIDFILE()
848
- with open(p, encoding="utf8") as f:
849
- d = json.load(f)
850
- if int(d.get("port", -1)) == int(SERVE.get("port", -2)) and not d.get("stopping"):
851
- d.update(model=key, path=path)
852
- with open(p + ".tmp", "w") as f:
853
- json.dump(d, f)
854
- os.replace(p + ".tmp", p)
855
- A.update_config(model=key)
856
- except Exception as e:
857
- print(f"[api] could not record the model switch: {e}", flush=True)
858
-
859
-
860
- def main(argv=None):
861
- global API_KEY, MODEL_KEY
862
- ap = argparse.ArgumentParser(prog="onw serve", description="chat UI + OpenAI-compatible API on the Intel NPU")
863
- ap.add_argument("model", help="onw model folder, or a Hugging Face repo id (downloaded into ./models)")
864
- ap.add_argument("--host", default="127.0.0.1")
865
- ap.add_argument("--port", type=int, default=8000)
866
- ap.add_argument("--device", default="NPU", help="run every component on this device (CPU works without an NPU driver)")
867
- ap.add_argument("--name", help="model id / title (default: engine directory name)")
868
- ap.add_argument("--context", type=int, default=None,
869
- help="context length in tokens (default 32768, at most the model's maximum); no recompile")
870
- ap.add_argument("--open", action="store_true", help="open the chat UI in the default browser once loaded")
871
- ap.add_argument("--no-pld", action="store_true", help="disable prompt lookup decoding")
872
- ap.add_argument("--api-key", help="require this key as a Bearer token on /v1 from other machines "
873
- "(e.g. with --host 0.0.0.0)")
874
- a = ap.parse_args(argv)
875
- API_KEY = a.api_key or os.environ.get("ONW_API_KEY") or None
876
- from onw.hub import resolve
877
- a.engine = resolve(a.model)
878
- import openvino as ov
879
- from . import npukit
880
- from onw.runtime import npu_info
881
- print(f"[onw] {__version__} | OpenVINO {ov.__version__} | {npu_info(npukit.core())}", flush=True)
882
- if a.device == "NPU" and "NPU" not in npukit.core().available_devices:
883
- print("WARNING: no NPU found (is the NPU driver installed?) - running everything on the CPU instead.", flush=True)
884
- a.device = "CPU"
885
- SERVE.update(device=a.device, context=a.context, pld=not a.no_pld, port=a.port)
886
- MODEL_KEY = os.environ.get("ONW_MODEL_KEY") or a.name
887
- load_model(a.engine, a.name)
888
- set_priority(False)
889
- if a.open:
890
- import webbrowser
891
- threading.Timer(1.5, webbrowser.open, [f"http://{'localhost' if a.host in ('0.0.0.0', '127.0.0.1') else a.host}:{a.port}/chat"]).start()
892
- print(f"\n OpenAI API: http://localhost:{a.port}/v1 (model id: {MODEL_ID})"
893
- f"\n Chat UI: http://localhost:{a.port}/chat\n Check page: http://localhost:{a.port}/check\n", flush=True)
894
- uvicorn.run(app, host=a.host, port=a.port, log_level="warning")
895
-
896
-
897
- if __name__ == "__main__":
898
- # (python -m onw.server: run the module that onw.compat put its routes on - this one is a second copy of it)
899
- from onw.server import main as _main
900
- _main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenAI-compatible API server for an onw model on the Intel NPU (point other apps at http://HOST:PORT/v1).
2
+
3
+ GET / what this server is: model, OpenAI base URL, endpoints (JSON)
4
+ GET /v1/models the loaded model with its limits: context_length (= max_model_len, max_context_length,
5
+ meta.n_ctx) and max_output_tokens (what a request without max_tokens gets), "loaded":
6
+ true; then the app's other models ("loaded": false). A request whose "model" names one
7
+ of them loads it first (the running one is closed: one model in memory at a time); a
8
+ name this server does not know is answered by the loaded model. ONW_API_SWITCH=0 (the
9
+ app's setting off): only the loaded model is listed / used
10
+ GET /v1/models/{id}
11
+ POST /v1/chat/completions (stream true/false; text and image_url parts: data: URLs or http(s)); responses
12
+ also carry "onw_stats" (prefill / decode speed, reused tokens) - extra field;
13
+ "usage" (token counts) in a whole answer and on a stream's last chunk
14
+ GET /health 200 when the model is loaded
15
+ GET /chat browser chat UI for trying the model
16
+ GET /bench benchmark page: prompt reading (prefill) and generation (decode) speed at 128 to 8K
17
+ tokens (/check, the old verification page, redirects here)
18
+ GET /api/bench_text ?tokens=N: prose of N tokens of this model's tokenizer (the benchmark's prompts)
19
+ GET /api/info model and device details
20
+
21
+ usage: onw serve MODEL [--port 8000] [--host 0.0.0.0] [--api-key KEY] [--device NPU|CPU] [--open]
22
+ CORS is open, so browser-based clients work too; with --api-key every /v1 request needs "Authorization: Bearer KEY"
23
+ (except from this PC's own programs and pages: the chat and check pages send no key).
24
+ Thinking mode: "chat_template_kwargs": {"enable_thinking": true}, or "reasoning_effort": "low" | "medium" | "high"
25
+ (gpt-oss uses the level; "none" / "minimal" turn thinking off). Whatever the model thought comes back as
26
+ message.reasoning_content (delta.reasoning_content when streaming; also for models that think with thinking off, as vLLM's
27
+ reasoning parsers do), the answer as content, and the thinking's token count as OpenAI's
28
+ usage.completion_tokens_details.reasoning_tokens.
29
+ Tool calls: "tools" (OpenAI format) -> message.tool_calls / finish_reason "tool_calls"; results as role "tool".
30
+ tool_choice "none" hides the tools; a named function narrows them to that one ("required" cannot be enforced).
31
+ response_format json_object / json_schema is best effort (an instruction is added; the output is not constrained).
32
+ Not supported: n > 1 (400), logprobs (null).
33
+ One NPU pipeline serves requests one at a time (a lock serializes them).
34
+ """
35
+ import argparse, asyncio, base64, io, ipaddress, json, os, re, socket, threading, time, uuid, urllib.parse, urllib.request
36
+
37
+ import uvicorn
38
+ from fastapi import FastAPI, HTTPException, Request
39
+ from fastapi.exceptions import RequestValidationError
40
+ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
41
+ from PIL import Image
42
+ from onw import __version__, i18n
43
+ from onw import tools as tool_calls
44
+
45
+ MODEL_ID = "npu-model" # set from the engine directory in main()
46
+ app = FastAPI(title="LLM on Intel NPU")
47
+ pipe = None # onw.chat.ChatEngine, created in main()
48
+ info = {}
49
+ lock = threading.Lock() # one generation at a time on the NPU
50
+ WEB = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
51
+ API_KEY = None # --api-key: required as a Bearer token on /v1
52
+ API_SAMPLING = None # sampling for requests that send none (ONW_API_SAMPLING: "recommended" or a JSON of values)
53
+ TOO_LONG = re.compile(r"the prompt is (\d+) tokens; the context holds (\d+)") # (ChatEngine.stream_chat's ValueError)
54
+
55
+
56
+ def oai_error(status, message, type_="invalid_request_error", code=None):
57
+ """An error in OpenAI's shape: {"error": {"message", "type", "code"}} (a prompt over the context: code
58
+ "context_length_exceeded", as OpenAI's)."""
59
+ code = code or ("context_length_exceeded" if TOO_LONG.match(message) else None)
60
+ return JSONResponse({"error": {"message": message, "type": type_, "param": None, "code": code}}, status_code=status)
61
+
62
+
63
+ def anthropic_message(message):
64
+ """Anthropic's wording for a prompt over the context ("prompt is too long: N tokens > M maximum" - Claude Code
65
+ compacts the conversation on it); other messages as they are."""
66
+ m = TOO_LONG.match(message)
67
+ return f"prompt is too long: {m[1]} tokens > {m[2]} maximum" if m else message
68
+
69
+
70
+ def api_error(request, status, message, type_="invalid_request_error", code=None):
71
+ """oai_error, or on the Anthropic Messages API (/v1/messages...) Anthropic's shape:
72
+ {"type": "error", "error": {"type", "message"}}."""
73
+ if request.url.path.startswith("/v1/messages"):
74
+ typ = {401: "authentication_error", 403: "permission_error", 404: "not_found_error",
75
+ 413: "request_too_large"}.get(status, "api_error" if status >= 500 else "invalid_request_error")
76
+ return JSONResponse({"type": "error", "error": {"type": typ, "message": anthropic_message(message)}},
77
+ status_code=status)
78
+ return oai_error(status, message, type_, code)
79
+
80
+
81
+ @app.exception_handler(HTTPException)
82
+ async def _http_error(request, e):
83
+ return api_error(request, e.status_code, str(e.detail),
84
+ "invalid_request_error" if e.status_code < 500 else "server_error")
85
+
86
+
87
+ @app.exception_handler(RequestValidationError)
88
+ async def _validation_error(request, e):
89
+ return api_error(request, 400, str(e))
90
+
91
+
92
+ @app.exception_handler(Exception)
93
+ async def _server_error(request, e):
94
+ return api_error(request, 500, f"{type(e).__name__}: {e}", "server_error")
95
+
96
+
97
+ CORS = [(b"access-control-allow-origin", b"*"), (b"access-control-allow-headers", b"*"),
98
+ (b"access-control-allow-methods", b"GET, POST, OPTIONS")]
99
+
100
+
101
+ class CorsAndKey:
102
+ """Open CORS (browser-based OpenAI clients) and the optional API key on /v1 (not asked of this PC's own programs and
103
+ pages - the chat and check pages send none; the key keeps the LAN out). A plain ASGI middleware, not
104
+ @app.middleware("http"): behind that one (Starlette's BaseHTTPMiddleware) request.is_disconnected() never turns
105
+ true, and a client that left a whole (non-streamed) answer kept the NPU busy to max_tokens."""
106
+ def __init__(self, inner):
107
+ self.inner = inner
108
+
109
+ async def __call__(self, scope, receive, send):
110
+ if scope["type"] != "http":
111
+ return await self.inner(scope, receive, send)
112
+ request = Request(scope) # (headers and client only: the body stays with the app)
113
+ path = scope["path"]
114
+ if path.startswith("/api/chats"): # saved conversations: this PC's own chat page only
115
+ why = _chats_refused(request)
116
+ if why:
117
+ return await JSONResponse({"error": why}, status_code=403)(scope, receive, send) # (no CORS)
118
+ return await self.inner(scope, receive, send)
119
+
120
+ async def with_cors(msg):
121
+ if msg["type"] == "http.response.start":
122
+ msg = {**msg, "headers": [h for h in msg.get("headers", []) if h[0].lower() not in dict(CORS)] + CORS}
123
+ await send(msg)
124
+ if scope["method"] == "OPTIONS":
125
+ return await JSONResponse({})(scope, receive, with_cors)
126
+ if API_KEY and path.startswith("/v1") and not _local(request) and \
127
+ request.headers.get("authorization", "") != f"Bearer {API_KEY}" and \
128
+ request.headers.get("x-api-key", "") != API_KEY: # (Anthropic clients send x-api-key)
129
+ return await api_error(request, 401, "invalid API key", code="invalid_api_key")(scope, receive, with_cors)
130
+ await self.inner(scope, receive, with_cors)
131
+
132
+
133
+ app.add_middleware(CorsAndKey)
134
+
135
+
136
+ # ---------------------------------------------------------------- saved conversations (the chat page's history)
137
+ LOCAL_HOSTS = ("localhost", "127.0.0.1", "[::1]", "::1")
138
+ CHAT_ID = __import__("re").compile(r"^[a-z0-9-]{8,48}$")
139
+ CHAT_MAX = 32 << 20 # bytes per conversation (images are kept small by the page)
140
+
141
+
142
+ def _chats_refused(request):
143
+ """Why a request for the saved conversations is refused, or None: only from this PC (also when the server listens on
144
+ the LAN), only under a localhost name (a rebound DNS name of some web site would otherwise pass as same-origin) and
145
+ only from the chat page itself (no cross-site reads or writes: these responses carry no CORS headers)."""
146
+ client = request.client.host if request.client else ""
147
+ try:
148
+ if not ipaddress.ip_address(client).is_loopback:
149
+ return "only from this PC"
150
+ except ValueError:
151
+ return "only from this PC"
152
+ host = urllib.parse.urlsplit("//" + request.headers.get("host", "")).hostname or ""
153
+ if host not in LOCAL_HOSTS:
154
+ return "only under localhost"
155
+ origin = request.headers.get("origin")
156
+ if origin and (urllib.parse.urlsplit(origin).hostname or "") not in LOCAL_HOSTS:
157
+ return "not from another site"
158
+ return None
159
+
160
+
161
+ def _local(request):
162
+ """True for a request from this PC itself as _chats_refused judges it (this PC's address, a localhost name, no other
163
+ site's page) and not one a proxy forwards (with X-Forwarded-For from a proxy on this PC, uvicorn already puts the
164
+ real client's address in request.client; a bare "Forwarded" it does not read)."""
165
+ return _chats_refused(request) is None and "forwarded" not in request.headers and \
166
+ "x-forwarded-for" not in request.headers
167
+
168
+
169
+ def chats_dir():
170
+ from onw import app as _app
171
+ d = os.path.join(_app.config_dir(), "chats")
172
+ os.makedirs(d, exist_ok=True)
173
+ return d
174
+
175
+
176
+ def _chat_path(cid):
177
+ if not CHAT_ID.match(cid or ""):
178
+ raise HTTPException(400, "bad conversation id")
179
+ return os.path.join(chats_dir(), cid + ".json")
180
+
181
+
182
+ @app.get("/api/chats")
183
+ def chats_list():
184
+ out = []
185
+ for f in os.listdir(chats_dir()):
186
+ if not f.endswith(".json"):
187
+ continue
188
+ try:
189
+ c = json.load(open(os.path.join(chats_dir(), f), encoding="utf8"))
190
+ out.append({k: c.get(k) for k in ("id", "title", "model", "created", "updated")} | {"n": len(c.get("messages", []))})
191
+ except (OSError, ValueError):
192
+ continue
193
+ return sorted(out, key=lambda c: c.get("updated") or 0, reverse=True)
194
+
195
+
196
+ @app.get("/api/chats/{cid}")
197
+ def chat_get(cid: str):
198
+ try:
199
+ return json.load(open(_chat_path(cid), encoding="utf8"))
200
+ except FileNotFoundError:
201
+ raise HTTPException(404, "no such conversation")
202
+
203
+
204
+ @app.put("/api/chats/{cid}")
205
+ async def chat_put(cid: str, request: Request):
206
+ p = _chat_path(cid)
207
+ raw = await request.body()
208
+ if len(raw) > CHAT_MAX:
209
+ raise HTTPException(413, "conversation too large")
210
+ try:
211
+ c = json.loads(raw)
212
+ assert isinstance(c, dict) and isinstance(c.get("messages"), list)
213
+ except (ValueError, AssertionError):
214
+ raise HTTPException(400, "not a conversation")
215
+ c["id"] = cid
216
+ with open(p + ".tmp", "w", encoding="utf8") as f:
217
+ json.dump(c, f, ensure_ascii=False)
218
+ os.replace(p + ".tmp", p)
219
+ return {"ok": True}
220
+
221
+
222
+ @app.delete("/api/chats/{cid}")
223
+ def chat_delete(cid: str):
224
+ try:
225
+ os.remove(_chat_path(cid))
226
+ except FileNotFoundError:
227
+ pass
228
+ return {"ok": True}
229
+
230
+
231
+ @app.delete("/api/chats")
232
+ def chats_delete_all():
233
+ n = 0
234
+ for f in os.listdir(chats_dir()):
235
+ if f.endswith(".json"):
236
+ try:
237
+ os.remove(os.path.join(chats_dir(), f))
238
+ n += 1
239
+ except OSError:
240
+ pass
241
+ return {"ok": True, "deleted": n}
242
+
243
+
244
+ def start_generation(msgs, kw):
245
+ """Run stream_chat in a worker thread; returns (asyncio queue of deltas/stats/exceptions, None at the end; stop
246
+ event). Called on the event loop: the worker hands its items over to it, so no thread waits per queued request."""
247
+ loop, q, stop = asyncio.get_running_loop(), asyncio.Queue(), threading.Event()
248
+
249
+ def put(x):
250
+ try:
251
+ loop.call_soon_threadsafe(q.put_nowait, x)
252
+ except RuntimeError: # the event loop is gone (the server is stopping)
253
+ stop.set()
254
+
255
+ def work():
256
+ try:
257
+ with lock:
258
+ if stop.is_set(): # the client left while this request waited for the NPU
259
+ return
260
+ set_priority(True)
261
+ try:
262
+ for d in pipe.stream_chat(msgs, should_stop=stop.is_set, split_reasoning=True, **kw):
263
+ put(d)
264
+ finally:
265
+ set_priority(False)
266
+ except Exception as e: # surfaced to the client
267
+ put(e)
268
+ finally:
269
+ put(None)
270
+ threading.Thread(target=work, daemon=True).start()
271
+ return q, stop
272
+
273
+
274
+ def reader(q, stop, request=None):
275
+ """-> async function returning start_generation's next item. With request (a whole response, not a stream): a
276
+ client that leaves meanwhile stops the generation (checked about once a second; a stream stops when it closes)."""
277
+ last = [time.monotonic()]
278
+
279
+ async def next_item():
280
+ while request is not None and not stop.is_set():
281
+ wait = last[0] + 1.0 - time.monotonic()
282
+ if wait <= 0:
283
+ last[0] = time.monotonic()
284
+ if await request.is_disconnected():
285
+ stop.set()
286
+ continue
287
+ try:
288
+ return await asyncio.wait_for(q.get(), wait)
289
+ except asyncio.TimeoutError:
290
+ pass
291
+ return await q.get()
292
+ return next_item
293
+
294
+
295
+ MAX_IMAGE = 20 << 20 # bytes
296
+
297
+
298
+ def _public_host(url):
299
+ """True when every address the URL's host resolves to is a public one: the server does not fetch from the local
300
+ network on a client's behalf (ONW_ALLOW_LOCAL_IMAGES=1 allows it)."""
301
+ if os.environ.get("ONW_ALLOW_LOCAL_IMAGES") == "1":
302
+ return True
303
+ host = urllib.parse.urlsplit(url).hostname
304
+ try:
305
+ addrs = {a[4][0] for a in socket.getaddrinfo(host, None)}
306
+ except (socket.gaierror, UnicodeError, TypeError):
307
+ return False
308
+ return bool(addrs) and all(ipaddress.ip_address(a.split("%")[0]).is_global for a in addrs)
309
+
310
+
311
+ def load_image(url):
312
+ try:
313
+ if url.startswith("data:"):
314
+ data = base64.b64decode(url.split(",", 1)[1])
315
+ elif url.startswith(("http://", "https://")):
316
+ if not _public_host(url):
317
+ raise HTTPException(400, "image_url: only public http(s) addresses are fetched (send a data: URL instead)")
318
+ req = urllib.request.Request(url, headers={"User-Agent": f"onw/{__version__}"})
319
+ with urllib.request.urlopen(req, timeout=20) as r:
320
+ data = r.read(MAX_IMAGE + 1)
321
+ if len(data) > MAX_IMAGE:
322
+ raise HTTPException(400, "image_url: the image is larger than 20 MB")
323
+ else:
324
+ raise HTTPException(400, "image_url must be a data: URL or http(s) URL")
325
+ return Image.open(io.BytesIO(data)).convert("RGB")
326
+ except HTTPException:
327
+ raise
328
+ except Exception as e: # bad base64, not an image, unreachable URL
329
+ raise HTTPException(400, f"image_url: {type(e).__name__}: {e}")
330
+
331
+
332
+ def to_hf_messages(messages):
333
+ """OpenAI chat messages -> HF chat format with PIL images."""
334
+ out = []
335
+ for m in tool_calls.history(messages):
336
+ keep = {k: m[k] for k in ("tool_calls", "tool_call_id", "name", "reasoning_content") if m.get(k) is not None}
337
+ c = m.get("content")
338
+ if c is None or isinstance(c, str):
339
+ out.append({"role": m["role"], "content": c or "", **keep})
340
+ continue
341
+ parts = []
342
+ for p in c:
343
+ if p.get("type") == "text":
344
+ parts.append({"type": "text", "text": p.get("text") or ""})
345
+ elif p.get("type") == "image_url":
346
+ iu = p.get("image_url") or {}
347
+ url = iu.get("url") if isinstance(iu, dict) else iu
348
+ if not isinstance(url, str):
349
+ raise HTTPException(400, "image_url part without a url")
350
+ parts.append({"type": "image", "image": load_image(url)})
351
+ else:
352
+ raise HTTPException(400, f"unsupported content part type: {p.get('type')}")
353
+ out.append({"role": m["role"], "content": parts, **keep})
354
+ return out
355
+
356
+
357
+ def _num(body, key, default, cast=float):
358
+ v = body.get(key)
359
+ if v is None:
360
+ return default
361
+ try:
362
+ return cast(v)
363
+ except (TypeError, ValueError):
364
+ raise HTTPException(400, f"{key} must be a number")
365
+
366
+
367
+ LEVELS = ("off", "low", "medium", "high") # the thinking levels of the window and the chat page (reasoning_effort)
368
+ _PREFS = [None, None] # (config mtime, this model's defaults)
369
+
370
+
371
+ def model_defaults():
372
+ """This model's defaults from the onw window (config model_prefs, read again when the file changes - no restart):
373
+ {"think": level, "sampling": "greedy" | "recommended" | "custom", values...}; {} when none were saved. Settings
374
+ of earlier versions (ONW_THINK_DEFAULT, ONW_API_SAMPLING) apply where these say nothing."""
375
+ key = os.environ.get("ONW_MODEL_KEY")
376
+ if not key:
377
+ return {}
378
+ try:
379
+ from onw import app as _app
380
+ p = os.path.join(_app.config_dir(), "config.json")
381
+ mt = os.path.getmtime(p)
382
+ if _PREFS[0] != mt:
383
+ _PREFS[:] = [mt, (_app.load_config().get("model_prefs") or {}).get(key) or {}]
384
+ return _PREFS[1]
385
+ except (OSError, ValueError):
386
+ return {}
387
+
388
+
389
+ def default_think():
390
+ """Thinking for a request that says nothing about it: the model's saved level, else the old switch."""
391
+ lvl = model_defaults().get("think")
392
+ if lvl == "off":
393
+ return False
394
+ if lvl in LEVELS:
395
+ return lvl
396
+ return True if os.environ.get("ONW_THINK_DEFAULT") == "1" else None
397
+
398
+
399
+ def default_sampling():
400
+ """Sampling for a request that sends none: the model's saved choice, else the old setting (API_SAMPLING)."""
401
+ d = model_defaults()
402
+ mode = d.get("sampling")
403
+ if mode == "greedy":
404
+ return None
405
+ if mode == "recommended":
406
+ return {k: v for k, v in (info.get("sampling") or {}).items() if k != "source"} or None
407
+ if mode == "custom":
408
+ return {k: d[k] for k in ("temperature", "top_p", "top_k", "presence_penalty") if k in d}
409
+ return API_SAMPLING
410
+
411
+
412
+ def gen_args(body):
413
+ """OpenAI request fields -> ChatEngine.stream_chat arguments."""
414
+ if _num(body, "n", 1, int) != 1:
415
+ raise HTTPException(400, "n > 1 is not supported (one NPU pipeline)")
416
+ stop = body.get("stop") or []
417
+ mx = body.get("max_completion_tokens", body.get("max_tokens"))
418
+ # a request without max_tokens: the setting (ONW_MAX_TOKENS; 0 = up to the end of the context - the engine stops
419
+ # there anyway); agents send none and write whole files in one answer
420
+ if mx is None:
421
+ mx = int(os.environ.get("ONW_MAX_TOKENS", "8192") or 0) or (1 << 30)
422
+ else:
423
+ mx = _num({"max_tokens": mx}, "max_tokens", 512, int)
424
+ if mx < 1:
425
+ raise HTTPException(400, "max_tokens must be at least 1")
426
+ think = (body.get("chat_template_kwargs") or {}).get("enable_thinking")
427
+ effort = body.get("reasoning_effort")
428
+ if isinstance(effort, str): # a level: low / medium / high (gpt-oss, llm-jp: trained;
429
+ think = False if effort in ("none", "minimal", "off") else effort # others: a thinking budget)
430
+ if think is None: # nothing said: the window's default for this model
431
+ think = default_think()
432
+ tools, choice = body.get("tools") or None, body.get("tool_choice")
433
+ if choice == "none":
434
+ tools = None
435
+ elif isinstance(choice, dict) and tools: # a named function: offer just that one
436
+ name = (choice.get("function") or {}).get("name")
437
+ tools = [t for t in tools if (t.get("function") or {}).get("name") == name] or tools
438
+ # what the request leaves out: greedy, or the settings' API defaults (API_SAMPLING; thinking has its own set)
439
+ dflt = dict(default_sampling() or {})
440
+ if think and isinstance(dflt.get("think"), dict):
441
+ dflt.update(dflt["think"])
442
+ return dict(max_new_tokens=mx, temperature=_num(body, "temperature", dflt.get("temperature", 0.0)),
443
+ top_p=_num(body, "top_p", dflt.get("top_p", 1.0)),
444
+ stop=[stop] if isinstance(stop, str) else list(stop), seed=body.get("seed"), think=think,
445
+ top_k=_num(body, "top_k", dflt.get("top_k", 0), int), repetition_penalty=_num(body, "repetition_penalty", 1.0),
446
+ presence_penalty=_num(body, "presence_penalty", dflt.get("presence_penalty", 0.0)),
447
+ frequency_penalty=_num(body, "frequency_penalty", 0.0), tools=tools)
448
+
449
+
450
+ def with_format(msgs, body):
451
+ """response_format json_object / json_schema: best effort - an instruction as a system message (the output is not
452
+ constrained)."""
453
+ rf = body.get("response_format") or {}
454
+ kind = rf.get("type") if isinstance(rf, dict) else None
455
+ if kind not in ("json_object", "json_schema"):
456
+ return msgs
457
+ note = "Respond with a single JSON object only, no other text."
458
+ if kind == "json_schema":
459
+ note += " It must follow this JSON schema: " + json.dumps((rf.get("json_schema") or {}).get("schema") or {},
460
+ ensure_ascii=False)
461
+ if msgs and msgs[0]["role"] == "system" and isinstance(msgs[0]["content"], str):
462
+ return [dict(msgs[0], content=msgs[0]["content"] + "\n\n" + note)] + msgs[1:]
463
+ return [{"role": "system", "content": note}] + msgs
464
+
465
+
466
+ @app.get("/")
467
+ def index(request: Request):
468
+ base = str(request.base_url).rstrip("/")
469
+ return {"object": "onw.server", "name": "onw - 俺のNPUがこんなに動くわけない", "version": __version__, "model": MODEL_ID,
470
+ "openai_base_url": base + "/v1", "api_key_required": bool(API_KEY),
471
+ "endpoints": {"models": "/v1/models", "chat_completions": "/v1/chat/completions", "health": "/health",
472
+ "chat_ui": "/chat", "bench_page": "/bench", "info": "/api/info"},
473
+ "context": info.get("max_context"), "max_output_tokens": max_output_tokens(), "vision": info.get("vision")}
474
+
475
+
476
+ @app.get("/health")
477
+ def health():
478
+ if SWITCHING.is_set() or pipe is None: # a model switch under way
479
+ return JSONResponse({"status": "loading", "model": MODEL_ID}, status_code=503)
480
+ return {"status": "ok", "model": MODEL_ID}
481
+
482
+
483
+ @app.get("/chat")
484
+ def chat_ui():
485
+ return HTMLResponse(i18n.page(os.path.join(WEB, "index.html")))
486
+
487
+
488
+ @app.get("/bench")
489
+ def bench_page():
490
+ return HTMLResponse(i18n.page(os.path.join(WEB, "bench.html")))
491
+
492
+
493
+ @app.get("/check")
494
+ def check_page(request: Request): # [bench] the old verification page's address
495
+ return RedirectResponse("/bench" + ("?" + request.url.query if request.url.query else ""))
496
+
497
+
498
+ # [bench] the benchmark page's prompts: ordinary prose, cut to an exact token count by the model's own tokenizer (so
499
+ # "2K tokens" is 2K for this model); every section differs a little, which keeps look-ahead (MTP / PLD) honest
500
+ BENCH_SENTENCES = [
501
+ "The harbor town woke slowly, its fishing boats rocking against the pier while gulls argued over the morning catch.",
502
+ "Engineers measured the bridge every spring, noting how the steel expanded in summer heat and contracted in winter.",
503
+ "A small library on the corner kept its lights on late, so students could read after their shifts at the factory.",
504
+ "Farmers in the valley rotated wheat, beans and clover, which kept the soil rich without much fertilizer.",
505
+ "The orchestra rehearsed the second movement twice, slowing the tempo until the strings and winds breathed together.",
506
+ "Old maps of the region show a river that has since moved two kilometers east, leaving a chain of quiet ponds.",
507
+ "In the laboratory, the team compared three batteries by charging them to full and draining them at a steady load.",
508
+ "Travelers crossing the pass in autumn carried extra blankets, because the weather could turn within an hour.",
509
+ "The bakery sold out of rye bread by noon on Saturdays, so regular customers learned to order it a day ahead.",
510
+ "Historians argue about why the trade route declined, pointing to new ships, higher taxes and a long drought.",
511
+ "A volunteer group repaired the hiking trail, replacing rotten steps and clearing the fallen trees after the storm.",
512
+ "The museum's new wing displays tools from daily life, from clay lamps to the first electric kettles.",
513
+ ]
514
+
515
+
516
+ @app.get("/api/bench_text")
517
+ def bench_text(tokens: int = 2048):
518
+ if pipe is None or not hasattr(pipe, "tok"):
519
+ raise HTTPException(503, "the model is not loaded")
520
+ n = max(8, min(int(tokens), 65536))
521
+ parts, k = [], 0
522
+ while True:
523
+ sec = " ".join(BENCH_SENTENCES[(k + i) % len(BENCH_SENTENCES)] for i in range(5))
524
+ parts.append(f"Section {k + 1}. {sec}")
525
+ k += 1
526
+ if k % 8 == 0 and len(pipe.tok("\n\n".join(parts), add_special_tokens=False)["input_ids"]) >= n:
527
+ break
528
+ ids = pipe.tok("\n\n".join(parts), add_special_tokens=False)["input_ids"][:n]
529
+ return {"text": pipe.tok.decode(ids, skip_special_tokens=True), "tokens": len(ids)}
530
+
531
+
532
+ @app.get("/api/info")
533
+ def api_info():
534
+ # the window's defaults for this model, read now (they change without a restart)
535
+ d, lvl = model_defaults(), default_think()
536
+ samp = default_sampling()
537
+ mode = d.get("sampling") or ("custom" if API_SAMPLING and os.environ.get("ONW_API_SAMPLING") != "recommended"
538
+ else "recommended" if API_SAMPLING else "greedy")
539
+ return {**info, "api_sampling": samp, "api_sampling_mode": mode,
540
+ "think_default": "off" if lvl is False else ("medium" if lvl is True else lvl),
541
+ "app": APP, "cpu": cpu_name(), "os": os_name()} # [bench] for the benchmark page's report
542
+
543
+
544
+ APP = f"{getattr(__import__(__package__ or 'onwx'), 'NAME', __package__ or 'onwx')} {__version__}"
545
+
546
+
547
+ def cpu_name():
548
+ """[bench] the processor's marketing name ("AMD Ryzen AI Max+ 395 w/ Radeon 8060S"), "" if unknown."""
549
+ try:
550
+ if os.name == "nt":
551
+ import winreg
552
+ with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"HARDWARE\DESCRIPTION\System\CentralProcessor\0") as k:
553
+ return str(winreg.QueryValueEx(k, "ProcessorNameString")[0]).strip()
554
+ for line in open("/proc/cpuinfo", encoding="utf8", errors="replace"):
555
+ if line.startswith("model name"):
556
+ return line.split(":", 1)[1].strip()
557
+ except Exception:
558
+ pass
559
+ return ""
560
+
561
+
562
+ def os_name():
563
+ """[bench] "Windows 11 (26200)" / "Ubuntu 24.04.3 LTS" ..."""
564
+ import platform
565
+ try:
566
+ if os.name == "nt":
567
+ build = int(platform.version().split(".")[-1])
568
+ return f"Windows {'11' if build >= 22000 else '10'} ({build})"
569
+ for line in open("/etc/os-release", encoding="utf8"):
570
+ if line.startswith("PRETTY_NAME="):
571
+ return line.split("=", 1)[1].strip().strip('"')
572
+ except Exception:
573
+ pass
574
+ return platform.platform(terse=True)
575
+
576
+
577
+ STARTED = int(time.time())
578
+
579
+
580
+ def max_output_tokens():
581
+ """The answer length a request without max_tokens gets (the setting; 0 = up to the context's end)."""
582
+ ctx = info.get("max_context") or 0
583
+ mx = int(os.environ.get("ONW_MAX_TOKENS", "8192") or 0)
584
+ return min(mx, ctx) if mx and ctx else (mx or ctx or None)
585
+
586
+
587
+ def _model_card(mid=None):
588
+ # the limits under the names clients read them from: context_length (OpenRouter / Open WebUI), max_model_len
589
+ # (vLLM), max_context_length (LM Studio), meta.n_ctx (llama.cpp's server). Another installed model (mid): the
590
+ # context setting it would load with (at most its own maximum, which shows once it is loaded).
591
+ loaded = mid is None or mid == MODEL_ID
592
+ ctx = info.get("max_context") if loaded else SERVE.get("context")
593
+ mx = int(os.environ.get("ONW_MAX_TOKENS", "8192") or 0)
594
+ out = max_output_tokens() if loaded else (min(mx, ctx) if mx and ctx else (mx or ctx or None))
595
+ return {"id": MODEL_ID if loaded else mid, "object": "model", "created": STARTED, "owned_by": "local-npu",
596
+ "loaded": loaded, "context_length": ctx, "max_model_len": ctx, "max_context_length": ctx,
597
+ "max_output_tokens": out, "meta": {"n_ctx": ctx}}
598
+
599
+
600
+ @app.get("/v1/models")
601
+ def models():
602
+ """The loaded model first, then (when switching is allowed) every other model the app has: a request naming one
603
+ loads it."""
604
+ others = [m for m in installed_models() if m != MODEL_ID] if switch_allowed() else []
605
+ return {"object": "list", "data": [_model_card()] + [_model_card(m) for m in others]}
606
+
607
+
608
+ @app.get("/v1/models/{model_id:path}")
609
+ def model(model_id: str):
610
+ if model_id == MODEL_ID:
611
+ return _model_card()
612
+ hit = next((m for m, (k, _) in installed_models().items() if model_id in (m, k)), None) if switch_allowed() else None
613
+ if hit is None:
614
+ raise HTTPException(404, f"model {model_id!r} not found (this server runs {MODEL_ID!r})")
615
+ return _model_card(hit)
616
+
617
+
618
+ def finalize(text, tools, finish):
619
+ """The answer's end: tool calls parsed out of the text (OpenAI tool_calls), finish_reason "tool_calls" when there
620
+ are any. -> (text, calls, finish_reason)."""
621
+ if not tools:
622
+ return text, [], finish
623
+ text, calls = tool_calls.parse(text, tools)
624
+ return text, calls, ("tool_calls" if calls else finish)
625
+
626
+
627
+ def usage_of(st):
628
+ return {"prompt_tokens": st["prompt_tokens"], "completion_tokens": st["completion_tokens"],
629
+ "total_tokens": st["prompt_tokens"] + st["completion_tokens"],
630
+ "prompt_tokens_details": {"cached_tokens": st.get("cached_tokens", 0)},
631
+ # (OpenAI's field: how many of the completion tokens were thinking)
632
+ "completion_tokens_details": {"reasoning_tokens": st.get("reasoning_tokens", 0)}}
633
+
634
+
635
+ def stats_of(st):
636
+ return {k: (round(v, 2) if isinstance(v, float) else v) for k, v in st.items() if k != "finish_reason"}
637
+
638
+
639
+ def worker_error(e):
640
+ """A generation error -> (HTTP status, OpenAI error type): the request's fault (a prompt too long, an image for a
641
+ text-only model) or the server's (the NPU, the driver)."""
642
+ return (400, "invalid_request_error") if isinstance(e, (ValueError, KeyError, TypeError)) else (500, "server_error")
643
+
644
+
645
+ @app.post("/v1/chat/completions")
646
+ async def chat(request: Request):
647
+ try:
648
+ body = await request.json()
649
+ except ValueError:
650
+ raise HTTPException(400, "the request body is not valid JSON")
651
+ if not isinstance(body, dict) or not isinstance(body.get("messages"), list):
652
+ raise HTTPException(400, "messages (a list) is required")
653
+ await ensure_model(body.get("model")) # another installed model named: load it
654
+ kw = gen_args(body)
655
+ loop = asyncio.get_running_loop()
656
+ try: # images may be downloaded: not on the event loop
657
+ msgs = await loop.run_in_executor(None, to_hf_messages, body["messages"])
658
+ except (KeyError, TypeError, AttributeError, ValueError) as e:
659
+ raise HTTPException(400, f"malformed messages: {e}")
660
+ msgs = with_format(msgs, body)
661
+ cid, created = f"chatcmpl-{uuid.uuid4().hex[:24]}", int(time.time())
662
+ tools = kw["tools"]
663
+ if body.get("stream"):
664
+ def chunk(delta, finish=None, **extra):
665
+ d = {"id": cid, "object": "chat.completion.chunk", "created": created, "model": MODEL_ID,
666
+ "choices": [{"index": 0, "delta": delta, "logprobs": None, "finish_reason": finish}], **extra}
667
+ return f"data: {json.dumps(d, ensure_ascii=False)}\n\n"
668
+
669
+ async def events():
670
+ full, sent = "", 0 # with tools: text is held back once a call may start
671
+ # (started here, not before the response: one whose stream never began would generate on to max_tokens)
672
+ q, stop = start_generation(msgs, kw)
673
+ next_item = reader(q, stop)
674
+ try:
675
+ yield chunk({"role": "assistant", "content": ""})
676
+ while True:
677
+ d = await next_item()
678
+ if d is None:
679
+ break
680
+ if isinstance(d, Exception):
681
+ status, typ = worker_error(d)
682
+ err = {"error": {"message": str(d), "type": typ, "param": None,
683
+ "code": "context_length_exceeded" if TOO_LONG.match(str(d)) else None}}
684
+ yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
685
+ elif isinstance(d, dict) and "reasoning" in d:
686
+ yield chunk({"reasoning_content": d["reasoning"]})
687
+ elif isinstance(d, dict):
688
+ rest, calls, finish = finalize(full, tools, d["finish_reason"])
689
+ if tools and rest.startswith(full[:sent]) and rest[sent:].strip(): # text after the calls
690
+ yield chunk({"content": rest[sent:]})
691
+ for i, tc in enumerate(calls):
692
+ yield chunk({"tool_calls": [dict(tc, index=i)]})
693
+ # the token counts on the last chunk too (as llama.cpp's server does): clients that do not
694
+ # send stream_options.include_usage still see them
695
+ yield chunk({}, finish, usage=usage_of(d), onw_stats=stats_of(d))
696
+ if (body.get("stream_options") or {}).get("include_usage"): # OpenAI: its own last chunk
697
+ yield f"data: {json.dumps({'id': cid, 'object': 'chat.completion.chunk', 'created': created, 'model': MODEL_ID, 'choices': [], 'usage': usage_of(d)})}\n\n"
698
+ elif tools:
699
+ full += d
700
+ upto = tool_calls.safe_upto(full)
701
+ if upto > sent:
702
+ yield chunk({"content": full[sent:upto]})
703
+ sent = upto
704
+ else:
705
+ yield chunk({"content": d})
706
+ yield "data: [DONE]\n\n"
707
+ finally: # client disconnected or finished: stop the NPU loop so the lock is released
708
+ stop.set()
709
+ return StreamingResponse(events(), media_type="text/event-stream",
710
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
711
+
712
+ parts, thought, st = [], [], None
713
+ q, stop = start_generation(msgs, kw)
714
+ next_item = reader(q, stop, request) # (a client that leaves stops the generation)
715
+ try:
716
+ while True:
717
+ d = await next_item()
718
+ if d is None:
719
+ break
720
+ if isinstance(d, Exception):
721
+ status, typ = worker_error(d)
722
+ return oai_error(status, str(d), typ)
723
+ if isinstance(d, dict) and "reasoning" in d:
724
+ thought.append(d["reasoning"])
725
+ elif isinstance(d, dict):
726
+ st = d
727
+ else:
728
+ parts.append(d)
729
+ finally:
730
+ stop.set()
731
+ if st is None:
732
+ return oai_error(500, "the generation ended without a result", "server_error")
733
+ text, calls, finish = finalize("".join(parts), tools, st["finish_reason"])
734
+ msg = {"role": "assistant", "content": (text.strip() or None) if tools else text}
735
+ if thought:
736
+ msg["reasoning_content"] = "".join(thought)
737
+ if calls:
738
+ msg["tool_calls"] = calls
739
+ return JSONResponse({
740
+ "id": cid, "object": "chat.completion", "created": created, "model": MODEL_ID,
741
+ "choices": [{"index": 0, "message": msg, "logprobs": None, "finish_reason": finish}],
742
+ "usage": usage_of(st),
743
+ "onw_stats": stats_of(st),
744
+ })
745
+
746
+
747
+ def recommended_sampling(arch, engine_dir=None):
748
+ """The chat page's starting sampling values ({temperature, top_p, top_k, presence_penalty?, think?, source}) or
749
+ None (greedy): the model's own generation_config.json first (Gemma 4, Granite 4.2), else onw/sampling.json by
750
+ model_type prefix. The API itself stays greedy unless asked."""
751
+ keys = ("temperature", "top_p", "top_k")
752
+ try:
753
+ with open(os.path.join(engine_dir, "generation_config.json"), encoding="utf8") as f:
754
+ g = json.load(f)
755
+ if g.get("do_sample") and any(k in g for k in keys):
756
+ return {"source": "model", "temperature": g.get("temperature", 1.0), "top_p": g.get("top_p", 1.0),
757
+ "top_k": g.get("top_k", 0)}
758
+ except (OSError, ValueError, TypeError):
759
+ pass
760
+ try:
761
+ with open(os.path.join(WEB, "..", "sampling.json"), encoding="utf8") as f:
762
+ table = {k: v for k, v in json.load(f).items() if not k.startswith("_")}
763
+ except (OSError, ValueError):
764
+ return None
765
+ hit = max((k for k in table if arch.startswith(k)), key=len, default=None)
766
+ if hit is None:
767
+ return None
768
+ return {k: v for k, v in table[hit].items() if k != "note"}
769
+
770
+
771
+ from onw import compat # noqa: E402,F401 (/v1/messages and /v1/responses on this app)
772
+
773
+
774
+ def set_priority(busy):
775
+ """Windows: the server's priority class - normal while loading and waiting, above normal while it generates
776
+ (busy). Windows puts a below-normal process on the low-power cores at low clocks (onw 1.4.5 and earlier started
777
+ the server below normal: Lunar Lake, Qwen3.5-9B NF4 with MTP ~12 tok/s, 16 above normal); generating takes one or two
778
+ cores, so the rest of the PC hardly notices. ONW_PRIORITY: the class while generating, normal | above (default) |
779
+ off (leave it as started)."""
780
+ want = os.environ.get("ONW_PRIORITY", "above")
781
+ if os.name != "nt" or want not in ("normal", "above"):
782
+ return
783
+ import ctypes
784
+ k32 = ctypes.WinDLL("kernel32")
785
+ k32.GetCurrentProcess.restype = ctypes.c_void_p
786
+ k32.SetPriorityClass.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
787
+ k32.SetPriorityClass(k32.GetCurrentProcess(), 0x8000 if busy and want == "above" else 0x20)
788
+
789
+
790
+ def load_model(path, name=None):
791
+ """Load a model as the one this server runs: pipe, MODEL_ID, info and the sampling defaults. main() and a
792
+ model switch (switch_model) both come here."""
793
+ global pipe, MODEL_ID, API_SAMPLING
794
+ from onw.chat import ChatEngine
795
+ pipe = ChatEngine(path, SERVE["device"], context=SERVE["context"], pld=SERVE["pld"])
796
+ MODEL_ID = name or os.path.basename(os.path.normpath(path))
797
+ v = pipe.vision
798
+ info.update(model=MODEL_ID, title=MODEL_ID, devices={"lm": SERVE["device"]}, fast=False, vision=bool(v),
799
+ image_px=v.get("image_px") if v else None,
800
+ think=pipe.think_style is not None, max_context=pipe.max_context,
801
+ always_thinks=pipe.think_style == "harmony", # (gpt-oss, llm-jp: the thinking counts against max_tokens)
802
+ note=f"文脈は約{pipe.max_context}トークン", context=pipe.max_context, pld=pipe.pld,
803
+ mtp=getattr(pipe.model, "mtp", None) is not None)
804
+ try:
805
+ with open(os.path.join(path, "config.json"), encoding="utf8") as f:
806
+ arch = json.load(f).get("model_type") or ""
807
+ except (OSError, ValueError):
808
+ arch = ""
809
+ # greedy Gemma on the NPU can fall into repeating one word (fp16 on the NPU tips a near tie; the same happens with
810
+ # any onw version): the pages suggest a repetition penalty, the API default stays 1.0 (OpenAI behaviour)
811
+ info.update(arch=arch, rep_hint=1.2 if arch.startswith("gemma") else None, sampling=recommended_sampling(arch, path))
812
+ want = os.environ.get("ONW_API_SAMPLING")
813
+ API_SAMPLING = None
814
+ if want == "recommended":
815
+ API_SAMPLING = {k: v for k, v in (info["sampling"] or {}).items() if k != "source"} or None
816
+ elif want:
817
+ try:
818
+ API_SAMPLING = json.loads(want)
819
+ except ValueError:
820
+ API_SAMPLING = None
821
+ info.update(api_sampling=API_SAMPLING, api_sampling_mode="recommended" if want == "recommended" else
822
+ "custom" if API_SAMPLING else "greedy")
823
+ if API_SAMPLING:
824
+ print(f"[api] requests without sampling values use {API_SAMPLING}", flush=True)
825
+
826
+
827
+ # ---------------------------------------------------------------- model choice from the API
828
+ # A request's "model" naming another model the app has (its id in /v1/models, or the app's key) unloads the running one
829
+ # and loads that one first (one model in memory at a time), as Ollama and LM Studio do. A name this server does not
830
+ # know - clients send their own defaults ("gpt-4o", ...) - is served by the loaded model, as before. The app's setting
831
+ # "api_model_switch" (ONW_API_SWITCH=0) turns switching off.
832
+ MODEL_KEY = None # the app's key of the loaded model (catalog repo id or folder name; ONW_MODEL_KEY)
833
+ SERVE = {} # how main() loads models: device, context, pld, port
834
+ SWITCHING = threading.Event() # set while a switch unloads / loads (/health answers 503: the window shows loading)
835
+
836
+
837
+ def switch_allowed():
838
+ return os.environ.get("ONW_API_SWITCH", "1") != "0"
839
+
840
+
841
+ def installed_models():
842
+ """{API id: (app key, path)} of every model the app finds in its folders (the ids of /v1/models)."""
843
+ try:
844
+ from onw import app as A
845
+ found = A.installed(A.load_config())
846
+ except Exception:
847
+ return {}
848
+ out = {}
849
+ for key, path in found.items():
850
+ if path and os.path.isdir(path):
851
+ out.setdefault(key.split("/")[-1], (key, path))
852
+ return out
853
+
854
+
855
+ def resolve_model(name):
856
+ """(app key, path) when a request's model names another installed model, else None (the loaded one serves)."""
857
+ if not name or not switch_allowed():
858
+ return None
859
+ n = str(name).strip().lower()
860
+ if n in ((MODEL_ID or "").lower(), (MODEL_KEY or "").lower()):
861
+ return None
862
+ for mid, (key, path) in installed_models().items():
863
+ if n in (mid.lower(), key.lower()):
864
+ return None if key == MODEL_KEY else (key, path)
865
+ return None
866
+
867
+
868
+ def switch_model(key, path):
869
+ """Unload the running model and load `key` (called with `lock` held, so no generation runs). If the new one does
870
+ not load, the old one is loaded again and the error goes to the client."""
871
+ global pipe, MODEL_KEY
872
+ import gc
873
+ old_key = MODEL_KEY
874
+ old_path = installed_models().get((MODEL_KEY or "").split("/")[-1], (None, None))[1]
875
+ print(f"[api] switching model: {MODEL_ID} -> {key.split('/')[-1]}", flush=True)
876
+ SWITCHING.set()
877
+ t = time.time()
878
+ try:
879
+ old = pipe
880
+ pipe = None
881
+ close = getattr(getattr(old, "model", None), "close", None)
882
+ if close:
883
+ close()
884
+ del old
885
+ gc.collect()
886
+ try:
887
+ os.environ["ONW_MODEL_KEY"] = key
888
+ load_model(path, key.split("/")[-1])
889
+ MODEL_KEY = key
890
+ except Exception:
891
+ if old_path: # back to the model that ran
892
+ os.environ["ONW_MODEL_KEY"] = old_key or ""
893
+ load_model(old_path, (old_key or "").split("/")[-1] or None)
894
+ raise
895
+ print(f"[api] {MODEL_ID} loaded in {time.time() - t:.1f}s", flush=True)
896
+ _record_model(key, path)
897
+ finally:
898
+ SWITCHING.clear()
899
+
900
+
901
+ async def ensure_model(name):
902
+ """Before a request's arguments are read (the sampling defaults and thinking style are the model's): the model it
903
+ names loaded, after any generation that runs now (the same lock). A failed load: 500 with why."""
904
+ if resolve_model(name) is None:
905
+ return
906
+
907
+ def work():
908
+ with lock:
909
+ want = resolve_model(name) # (another request may have switched to it meanwhile)
910
+ if want:
911
+ switch_model(*want)
912
+ try:
913
+ await asyncio.get_running_loop().run_in_executor(None, work)
914
+ except Exception as e:
915
+ raise HTTPException(500, f"model {name!r} could not be loaded: {type(e).__name__}: {e}")
916
+
917
+
918
+ def _record_model(key, path):
919
+ """The window and the next start follow the switch: the server record (pid file) and the chosen model."""
920
+ try:
921
+ from onw import app as A
922
+ p = A.PIDFILE()
923
+ with open(p, encoding="utf8") as f:
924
+ d = json.load(f)
925
+ if int(d.get("port", -1)) == int(SERVE.get("port", -2)) and not d.get("stopping"):
926
+ d.update(model=key, path=path)
927
+ with open(p + ".tmp", "w") as f:
928
+ json.dump(d, f)
929
+ os.replace(p + ".tmp", p)
930
+ A.update_config(model=key)
931
+ except Exception as e:
932
+ print(f"[api] could not record the model switch: {e}", flush=True)
933
+
934
+
935
+ def main(argv=None):
936
+ global API_KEY, MODEL_KEY
937
+ ap = argparse.ArgumentParser(prog="onw serve", description="chat UI + OpenAI-compatible API on the Intel NPU")
938
+ ap.add_argument("model", help="onw model folder, or a Hugging Face repo id (downloaded into ./models)")
939
+ ap.add_argument("--host", default="127.0.0.1")
940
+ ap.add_argument("--port", type=int, default=8000)
941
+ ap.add_argument("--device", default="NPU", help="run every component on this device (CPU works without an NPU driver)")
942
+ ap.add_argument("--name", help="model id / title (default: engine directory name)")
943
+ ap.add_argument("--context", type=int, default=None,
944
+ help="context length in tokens (default 32768, at most the model's maximum); no recompile")
945
+ ap.add_argument("--open", action="store_true", help="open the chat UI in the default browser once loaded")
946
+ ap.add_argument("--no-pld", action="store_true", help="disable prompt lookup decoding")
947
+ ap.add_argument("--api-key", help="require this key as a Bearer token on /v1 from other machines "
948
+ "(e.g. with --host 0.0.0.0)")
949
+ a = ap.parse_args(argv)
950
+ API_KEY = a.api_key or os.environ.get("ONW_API_KEY") or None
951
+ from onw.hub import resolve
952
+ a.engine = resolve(a.model)
953
+ import openvino as ov
954
+ from . import npukit
955
+ from onw.runtime import npu_info
956
+ print(f"[onw] {__version__} | OpenVINO {ov.__version__} | {npu_info(npukit.core())}", flush=True)
957
+ if a.device == "NPU" and "NPU" not in npukit.core().available_devices:
958
+ print("WARNING: no NPU found (is the NPU driver installed?) - running everything on the CPU instead.", flush=True)
959
+ a.device = "CPU"
960
+ SERVE.update(device=a.device, context=a.context, pld=not a.no_pld, port=a.port)
961
+ MODEL_KEY = os.environ.get("ONW_MODEL_KEY") or a.name
962
+ load_model(a.engine, a.name)
963
+ set_priority(False)
964
+ if a.open:
965
+ import webbrowser
966
+ threading.Timer(1.5, webbrowser.open, [f"http://{'localhost' if a.host in ('0.0.0.0', '127.0.0.1') else a.host}:{a.port}/chat"]).start()
967
+ print(f"\n OpenAI API: http://localhost:{a.port}/v1 (model id: {MODEL_ID})"
968
+ f"\n Chat UI: http://localhost:{a.port}/chat\n Benchmark: http://localhost:{a.port}/bench\n", flush=True)
969
+ uvicorn.run(app, host=a.host, port=a.port, log_level="warning")
970
+
971
+
972
+ if __name__ == "__main__":
973
+ # (python -m onw.server: run the module that onw.compat put its routes on - this one is a second copy of it)
974
+ from onw.server import main as _main
975
+ _main()
onw/tray.py CHANGED
@@ -221,7 +221,7 @@ def main(argv=None):
221
  pystray.MenuItem(lambda _: T("onw を開く(モデル・サーバー・設定)"), lambda *_: open_window(), default=True),
222
  # (the running server's port: one changed in the settings applies from the next start)
223
  pystray.MenuItem(lambda _: T("チャットを開く"), lambda *_: webbrowser.open(f"http://127.0.0.1:{app.server_port(cfg())}/chat"), enabled=running),
224
- pystray.MenuItem(lambda _: T("検証ページを開く"), lambda *_: webbrowser.open(f"http://127.0.0.1:{app.server_port(cfg())}/check"), enabled=running),
225
  pystray.MenuItem(lambda _: T("OpenAI API のURLをコピー"),
226
  lambda *_: copy_text(app.api_url(cfg())) or open_window("server")), # no clipboard tool: shown there
227
  pystray.MenuItem(lambda _: T("onw を更新(v{0})", manager.upd["latest"]), do_update,
 
221
  pystray.MenuItem(lambda _: T("onw を開く(モデル・サーバー・設定)"), lambda *_: open_window(), default=True),
222
  # (the running server's port: one changed in the settings applies from the next start)
223
  pystray.MenuItem(lambda _: T("チャットを開く"), lambda *_: webbrowser.open(f"http://127.0.0.1:{app.server_port(cfg())}/chat"), enabled=running),
224
+ pystray.MenuItem(lambda _: T("ベンチマークを開く"), lambda *_: webbrowser.open(f"http://127.0.0.1:{app.server_port(cfg())}/bench"), enabled=running),
225
  pystray.MenuItem(lambda _: T("OpenAI API のURLをコピー"),
226
  lambda *_: copy_text(app.api_url(cfg())) or open_window("server")), # no clipboard tool: shown there
227
  pystray.MenuItem(lambda _: T("onw を更新(v{0})", manager.upd["latest"]), do_update,
onw/web/{check.html → bench.html} RENAMED
@@ -4,7 +4,7 @@
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%2270 90 846 846%22%3E%3Crect x=%22332%22 y=%22256%22 width=%22512%22 height=%22511%22 rx=%2280%22 fill=%22%231868f0%22/%3E%3Cline x1=%22181%22 y1=%22360%22 x2=%22208%22 y2=%22360%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22181%22 y1=%22664%22 x2=%22212%22 y2=%22664%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22436%22 y1=%22212%22 x2=%22436%22 y2=%22300%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22436%22 y1=%22815%22 x2=%22436%22 y2=%22720%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22512%22 y1=%22212%22 x2=%22512%22 y2=%22300%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22512%22 y1=%22815%22 x2=%22512%22 y2=%22720%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22588%22 y1=%22212%22 x2=%22588%22 y2=%22300%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22588%22 y1=%22815%22 x2=%22588%22 y2=%22720%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22664%22 y1=%22212%22 x2=%22664%22 y2=%22300%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22664%22 y1=%22815%22 x2=%22664%22 y2=%22720%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22740%22 y1=%22212%22 x2=%22740%22 y2=%22300%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22740%22 y1=%22815%22 x2=%22740%22 y2=%22720%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22891%22 y1=%22360%22 x2=%22800%22 y2=%22360%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22891%22 y1=%22436%22 x2=%22800%22 y2=%22436%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22891%22 y1=%22512%22 x2=%22800%22 y2=%22512%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22891%22 y1=%22588%22 x2=%22800%22 y2=%22588%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22891%22 y1=%22664%22 x2=%22800%22 y2=%22664%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22268%22 y1=%22360%22 x2=%22380%22 y2=%22360%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22124%22 y1=%22436%22 x2=%22380%22 y2=%22436%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22209%22 y1=%22512%22 x2=%22380%22 y2=%22512%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%2295%22 y1=%22588%22 x2=%22380%22 y2=%22588%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22275%22 y1=%22664%22 x2=%22380%22 y2=%22664%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cellipse cx=%22588%22 cy=%22512%22 rx=%22120%22 ry=%22117%22 fill=%22%23fff%22/%3E%3Cellipse cx=%22588%22 cy=%22512%22 rx=%2257%22 ry=%2267%22 fill=%22%231868f0%22/%3E%3C/svg%3E">
7
- <title>onw 検証ページ</title>
8
  <script>/*i18n*/</script>
9
  <style>
10
  :root { --bg: #f6f7f9; --fg: #1d2330; --muted: #5d6675; --card: #fff; --line: #dfe3ea; --ok: #1f8a4c; --bad: #c0392b; --accent: #2f6fde; }
@@ -17,34 +17,50 @@ h1 { font-size: 22px; margin: 0 0 4px; }
17
  .card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; margin-bottom: 14px; }
18
  code { background: rgba(127,127,127,.15); padding: 1px 5px; border-radius: 4px; }
19
  button { font: inherit; padding: 8px 16px; border-radius: 8px; border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; }
 
20
  button:disabled { opacity: .5; cursor: default; }
21
  table { width: 100%; border-collapse: collapse; }
22
  th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
23
  th { color: var(--muted); font-weight: 600; font-size: 13px; }
24
- td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
25
- .ok { color: var(--ok); } .bad { color: var(--bad); }
26
- .ans { color: var(--muted); font-size: 13px; max-width: 420px; }
27
  #info td:first-child { color: var(--muted); width: 180px; }
 
 
 
28
  </style>
29
  </head>
30
  <body>
31
  <main>
32
- <h1>onw 検証ページ</h1>
33
- <p class="sub">このサーバー(OpenAI互換API)に実際のリクエストを送って、動作と速度を確かめます。</p>
34
 
35
  <div class="card">
36
  <table id="info"><tbody><tr><td>読み込み中…</td><td></td></tr></tbody></table>
37
  </div>
38
 
39
  <div class="card">
40
- <button id="run">検証を実行</button>
41
- <label class="sub" style="margin-left:10px">繰り返し抑制 <input id="rep" type="number" min="1" max="2" step="0.05" value="1" style="width:60px"></label>
42
- <span id="rephint" class="sub" style="display:none"></span>
 
 
 
 
 
 
 
 
 
 
 
43
  <span id="status" class="sub" style="margin-left:10px"></span>
44
  <table style="margin-top:12px">
45
- <thead><tr><th>項目</th><th>結果</th><th>プロンプト</th><th>処理時間</th><th>生成速度</th><th>回答</th></tr></thead>
46
  <tbody id="rows"></tbody>
47
  </table>
 
48
  </div>
49
 
50
  <div class="card sub" data-t>他のアプリからは <code id="base"></code> を OpenAI の base URL に、モデル名は <code id="mid"></code> を指定してください(APIキー設定時は <code>Authorization: Bearer キー</code>)。</div>
@@ -52,83 +68,105 @@ td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
52
  <script>
53
  translate(document.body);
54
  const $ = id => document.getElementById(id);
55
- let INFO = {}, KEY = new URLSearchParams(location.search).get("key") || "";
56
- const TEXT = T("東京は日本の首都であり、政治・経済・文化の中心地である。江戸時代には徳川幕府が置かれ、1868年の明治維新で東京と改称された。現在は約1400万人が暮らす世界有数の大都市で、多くの企業や大学が集まっている。");
57
 
58
  const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
 
59
 
60
  async function api(messages, max_tokens) {
61
- // thinking at its lowest ("off"; gpt-oss / llm-jp always think - at "low" - and that counts against max_tokens)
62
  if (INFO.always_thinks) max_tokens += 1024;
63
- const t0 = performance.now();
64
  const r = await fetch("/v1/chat/completions", {
65
  method: "POST",
66
  headers: Object.assign({ "Content-Type": "application/json" }, KEY ? { Authorization: "Bearer " + KEY } : {}),
67
- body: JSON.stringify({ model: INFO.model, messages, max_tokens, temperature: 0, repetition_penalty: +$("rep").value || 1,
68
- reasoning_effort: "off" })
69
  });
70
  if (!r.ok) throw new Error((await r.text()).slice(0, 200));
71
- const j = await r.json(), c = j.choices[0];
72
- if (!(c.message.content || "").trim()) throw new Error(T("回答が空です(最大トークンを思考で使い切った可能性があります)"));
73
- if (c.finish_reason === "length") throw new Error(T("最大ト���クンで打ち切られました") + ": " + c.message.content);
74
- j.wall = (performance.now() - t0) / 1000;
75
- return j;
76
  }
77
 
78
- function row(name, j, err) {
79
- const tr = document.createElement("tr");
80
- if (err) {
81
- tr.innerHTML = `<td>${esc(name)}</td><td class="bad">${T("失敗")}</td><td colspan="4" class="ans">${esc(err)}</td>`;
82
- } else {
83
- const s = j.onw_stats || {}, u = j.usage || {};
84
- const cached = u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens ? T("(再利用 {0})", u.prompt_tokens_details.cached_tokens) : "";
85
- const vis = s.vision_ms ? T(" + 画像 {0}秒", (s.vision_ms / 1000).toFixed(1)) : "";
86
- const ans = esc(j.choices[0].message.content);
87
- tr.innerHTML = `<td>${esc(name)}</td><td class="ok">OK</td><td class="num">${esc(u.prompt_tokens)}${cached}</td>` +
88
- `<td class="num">${T("{0}秒", ((s.prefill_ms || 0) / 1000).toFixed(2))}${vis}</td>` +
89
- `<td class="num">${(s.decode_tok_s || 0).toFixed(1)} tok/s</td><td class="ans">${ans}</td>`;
90
- }
91
- $("rows").appendChild(tr);
 
 
 
92
  }
93
 
94
- function testImage() { // drawn here, so no image file is needed
95
- const c = document.createElement("canvas"); c.width = c.height = 512;
96
- const g = c.getContext("2d");
97
- g.fillStyle = "#ffffff"; g.fillRect(0, 0, 512, 512);
98
- g.fillStyle = "#e53935"; g.beginPath(); g.arc(150, 170, 90, 0, Math.PI * 2); g.fill();
99
- g.fillStyle = "#1e88e5"; g.fillRect(290, 90, 160, 160);
100
- g.fillStyle = "#111111"; g.font = "bold 96px sans-serif"; g.fillText("NPU", 150, 430);
101
- return c.toDataURL("image/png");
102
  }
103
 
104
  async function run() {
105
- $("run").disabled = true; $("rows").innerHTML = "";
106
- const steps = [
107
- [T("テキスト生成"), () => api([{ role: "user", content: T("日本の四季について、それぞれ一文ずつ説明してください。") }], 320)],
108
- [T("原文を再利用する要約(PLD)"), () => api([{ role: "user", content: T("次の文章を、元の表現をなるべく使って2文に要約してください。") + "\n\n" + TEXT }], 240)],
109
- ];
110
- const t1 = [{ role: "user", content: T("日本の首都はどこですか?一文で。") }];
111
- steps.push([T("2ターン会話: 1ターン目"), async () => { const j = await api(t1, 120); t1.push({ role: "assistant", content: j.choices[0].message.content }); return j; }]);
112
- steps.push([T("2ターン会話: 2ターン目"), () => api(t1.concat([{ role: "user", content: T("その都市の人口は?一文で。") }]), 120)]);
113
- if (INFO.vision) steps.push([T("画像入力"), () => api([{ role: "user", content: [
114
- { type: "image_url", image_url: { url: testImage() } },
115
- { type: "text", text: T("この画像に描かれている図形と文字を、色も含めて一文で答えてください。") }] }], 160)]);
116
- for (const [name, f] of steps) {
117
- $("status").textContent = name + " …";
118
- try { row(name, await f()); } catch (e) { row(name, null, e.message); }
 
 
 
 
 
 
 
 
 
119
  }
120
- $("status").textContent = T("完了"); $("run").disabled = false;
121
  }
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  fetch("/api/info").then(r => r.json()).then(i => {
124
  INFO = i;
125
- const rows = [[T("モデル名"), i.model], [T("デバイス"), Object.values(i.devices || {}).join(", ")],
126
- [T("コンテキスト長"), T("{0} トークン", i.context || i.max_context)], [T("画像入力"), i.vision ? T("対応") : T("なし")],
127
- [T("先読み検証(PLD)"), i.pld && !i.mtp ? T("有効") : T("無効")], [T("MTP 先読み"), i.mtp ? T("有効") : T("なし")],
128
- [T("思考モード"), i.think ? T("対応") : T("なし")]];
129
  $("info").innerHTML = rows.map(([k, v]) => `<tr><td>${esc(k)}</td><td>${esc(v)}</td></tr>`).join("");
130
  $("base").textContent = location.origin + "/v1"; $("mid").textContent = i.model;
131
- if (i.rep_hint) { $("rephint").textContent = T("{0} 以上推奨(1 では同じ言葉を繰り返すことがあります)", i.rep_hint); $("rephint").style.display = ""; }
 
132
  }).catch(() => { $("info").innerHTML = `<tr><td class='bad'>${T("サーバーに接続できません")}</td></tr>`; });
133
  $("run").onclick = run;
134
  </script>
 
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%2270 90 846 846%22%3E%3Crect x=%22332%22 y=%22256%22 width=%22512%22 height=%22511%22 rx=%2280%22 fill=%22%231868f0%22/%3E%3Cline x1=%22181%22 y1=%22360%22 x2=%22208%22 y2=%22360%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22181%22 y1=%22664%22 x2=%22212%22 y2=%22664%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22436%22 y1=%22212%22 x2=%22436%22 y2=%22300%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22436%22 y1=%22815%22 x2=%22436%22 y2=%22720%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22512%22 y1=%22212%22 x2=%22512%22 y2=%22300%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22512%22 y1=%22815%22 x2=%22512%22 y2=%22720%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22588%22 y1=%22212%22 x2=%22588%22 y2=%22300%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22588%22 y1=%22815%22 x2=%22588%22 y2=%22720%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22664%22 y1=%22212%22 x2=%22664%22 y2=%22300%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22664%22 y1=%22815%22 x2=%22664%22 y2=%22720%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22740%22 y1=%22212%22 x2=%22740%22 y2=%22300%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22740%22 y1=%22815%22 x2=%22740%22 y2=%22720%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22891%22 y1=%22360%22 x2=%22800%22 y2=%22360%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22891%22 y1=%22436%22 x2=%22800%22 y2=%22436%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22891%22 y1=%22512%22 x2=%22800%22 y2=%22512%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22891%22 y1=%22588%22 x2=%22800%22 y2=%22588%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22891%22 y1=%22664%22 x2=%22800%22 y2=%22664%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22268%22 y1=%22360%22 x2=%22380%22 y2=%22360%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22124%22 y1=%22436%22 x2=%22380%22 y2=%22436%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22209%22 y1=%22512%22 x2=%22380%22 y2=%22512%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%2295%22 y1=%22588%22 x2=%22380%22 y2=%22588%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cline x1=%22275%22 y1=%22664%22 x2=%22380%22 y2=%22664%22 stroke=%22%231868f0%22 stroke-width=%2238%22 stroke-linecap=%22round%22/%3E%3Cellipse cx=%22588%22 cy=%22512%22 rx=%22120%22 ry=%22117%22 fill=%22%23fff%22/%3E%3Cellipse cx=%22588%22 cy=%22512%22 rx=%2257%22 ry=%2267%22 fill=%22%231868f0%22/%3E%3C/svg%3E">
7
+ <title>ベンチマーク</title>
8
  <script>/*i18n*/</script>
9
  <style>
10
  :root { --bg: #f6f7f9; --fg: #1d2330; --muted: #5d6675; --card: #fff; --line: #dfe3ea; --ok: #1f8a4c; --bad: #c0392b; --accent: #2f6fde; }
 
17
  .card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; margin-bottom: 14px; }
18
  code { background: rgba(127,127,127,.15); padding: 1px 5px; border-radius: 4px; }
19
  button { font: inherit; padding: 8px 16px; border-radius: 8px; border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; }
20
+ button.ghost { background: transparent; color: var(--accent); }
21
  button:disabled { opacity: .5; cursor: default; }
22
  table { width: 100%; border-collapse: collapse; }
23
  th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
24
  th { color: var(--muted); font-weight: 600; font-size: 13px; }
25
+ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
26
+ td.big { font-weight: 700; }
27
+ .bad { color: var(--bad); }
28
  #info td:first-child { color: var(--muted); width: 180px; }
29
+ .opts { display: flex; flex-wrap: wrap; gap: 6px 16px; align-items: center; margin-bottom: 10px; }
30
+ .opts label { white-space: nowrap; }
31
+ .note { color: var(--muted); font-size: 13px; margin-top: 10px; }
32
  </style>
33
  </head>
34
  <body>
35
  <main>
36
+ <h1 id="h1">ベンチマーク</h1>
37
+ <p class="sub">このサーバーで、プロンプトの読み込み(プリフィル)と回答の生成(デコード)の速さを測ります。</p>
38
 
39
  <div class="card">
40
  <table id="info"><tbody><tr><td>読み込み中…</td><td></td></tr></tbody></table>
41
  </div>
42
 
43
  <div class="card">
44
+ <div class="opts">
45
+ <span>プロンプトの長さ:</span>
46
+ <label><input type="checkbox" class="len" value="128" checked> 128</label>
47
+ <label><input type="checkbox" class="len" value="1024" checked> 1K</label>
48
+ <label><input type="checkbox" class="len" value="2048" checked> 2K</label>
49
+ <label><input type="checkbox" class="len" value="4096" checked> 4K</label>
50
+ <label><input type="checkbox" class="len" value="8192"> 8K</label>
51
+ </div>
52
+ <div class="opts">
53
+ <label>生成するトークン数 <select id="gen"><option>128</option><option selected>256</option><option>512</option></select></label>
54
+ <label>回数 <select id="reps"><option>1</option><option selected>2</option><option>3</option></select></label>
55
+ </div>
56
+ <button id="run">ベンチマークを実行</button>
57
+ <button id="copy" class="ghost" disabled>結果をコピー(Markdown)</button>
58
  <span id="status" class="sub" style="margin-left:10px"></span>
59
  <table style="margin-top:12px">
60
+ <thead><tr><th>プロンプト</th><th class="num">プリフィル</th><th class="num">プリフィル速度</th><th class="num">生成</th><th class="num">生成速度</th></tr></thead>
61
  <tbody id="rows"></tbody>
62
  </table>
63
+ <div class="note">プリフィル=プロンプトを読み終えるまで(最初のトークンが出るまで)の時間と、1秒あたりに読んだトークン数。生成=そのあと回答を書く速さ(デコード)。温度 0、思考オフ。毎回、文頭に違う一文を入れて、前の会話を再利用しないようにしています。回数を増やすと、いちばん速かった回を表示します(最初に短い慣らし運転を1回します)。</div>
64
  </div>
65
 
66
  <div class="card sub" data-t>他のアプリからは <code id="base"></code> を OpenAI の base URL に、モデル名は <code id="mid"></code> を指定してください(APIキー設定時は <code>Authorization: Bearer キー</code>)。</div>
 
68
  <script>
69
  translate(document.body);
70
  const $ = id => document.getElementById(id);
71
+ let INFO = {}, KEY = new URLSearchParams(location.search).get("key") || "", RESULTS = [];
 
72
 
73
  const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
74
+ const fmt = (x, d) => (x == null || !isFinite(x)) ? "-" : Number(x).toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d });
75
 
76
  async function api(messages, max_tokens) {
77
+ // thinking at its lowest ("off"; models that always think do it at "low" - that counts against max_tokens)
78
  if (INFO.always_thinks) max_tokens += 1024;
 
79
  const r = await fetch("/v1/chat/completions", {
80
  method: "POST",
81
  headers: Object.assign({ "Content-Type": "application/json" }, KEY ? { Authorization: "Bearer " + KEY } : {}),
82
+ body: JSON.stringify({ model: INFO.model, messages, max_tokens, temperature: 0, reasoning_effort: "off" })
 
83
  });
84
  if (!r.ok) throw new Error((await r.text()).slice(0, 200));
85
+ return r.json();
 
 
 
 
86
  }
87
 
88
+ // a prompt of about n tokens from the server's own tokenizer (so 2K means 2K for this model), started by a sentence
89
+ // that differs every time: nothing of an earlier prompt is reused
90
+ async function prompt(n, run) {
91
+ const ask = T("上の文章の要点を、できるだけ詳しく説明してください。");
92
+ const r = await fetch(`/api/bench_text?tokens=${Math.max(16, n - 48)}`);
93
+ if (!r.ok) throw new Error((await r.text()).slice(0, 200));
94
+ const t = await r.json();
95
+ return [{ role: "user", content: T("ベンチマーク {0}({1})", run, Date.now()) + "\n\n" + t.text + "\n\n" + ask }];
96
+ }
97
+
98
+ function measure(j) {
99
+ const s = j.onwx_stats || j.onw_stats || {}, u = j.usage || {};
100
+ const reused = u.prompt_tokens_details?.cached_tokens || 0;
101
+ const pre = (s.prefill_ms || 0) / 1000;
102
+ return { prompt: u.prompt_tokens || 0, reused, prefill_s: pre,
103
+ prefill_tps: pre > 0 ? (u.prompt_tokens - reused) / pre : null,
104
+ gen: (u.completion_tokens || 0), decode_tps: s.decode_tok_s || null };
105
  }
106
 
107
+ function show() {
108
+ $("rows").innerHTML = RESULTS.map(r => r.err
109
+ ? `<tr><td>${esc(r.label)}</td><td colspan="4" class="bad">${esc(r.err)}</td></tr>`
110
+ : `<tr><td>${esc(r.label)}${r.reused ? " " + esc(T("(再利用 {0})", r.reused)) : ""}</td>` +
111
+ `<td class="num">${esc(T("{0}秒", fmt(r.prefill_s, 2)))}</td><td class="num big">${fmt(r.prefill_tps, 0)} tok/s</td>` +
112
+ `<td class="num">${esc(T("{0}トークン", r.gen))}</td><td class="num big">${fmt(r.decode_tps, 1)} tok/s</td></tr>`).join("");
 
 
113
  }
114
 
115
  async function run() {
116
+ const lens = [...document.querySelectorAll(".len")].filter(c => c.checked).map(c => +c.value);
117
+ const gen = +$("gen").value, reps = +$("reps").value;
118
+ const ctx = INFO.context || INFO.max_context || 0;
119
+ $("run").disabled = true; $("copy").disabled = true; RESULTS = []; show();
120
+ try {
121
+ $("status").textContent = T("慣らし運転…");
122
+ await api(await prompt(64, 0), 8);
123
+ } catch (e) { $("status").textContent = T("失敗") + ": " + e.message; $("run").disabled = false; return; }
124
+ let k = 0;
125
+ for (const n of lens) {
126
+ const label = T("{0} トークン", n.toLocaleString());
127
+ if (ctx && n + gen + 64 > ctx) { RESULTS.push({ label, err: T("コンテキスト長({0})を超えるので飛ばしました", ctx) }); show(); continue; }
128
+ let best = null;
129
+ for (let i = 0; i < reps; i++) {
130
+ $("status").textContent = T("{0}({1}/{2}回目)…", label, i + 1, reps);
131
+ try {
132
+ const m = measure(await api(await prompt(n, ++k), gen));
133
+ best = best ? { ...best, prefill_s: Math.min(best.prefill_s, m.prefill_s),
134
+ prefill_tps: Math.max(best.prefill_tps || 0, m.prefill_tps || 0),
135
+ decode_tps: Math.max(best.decode_tps || 0, m.decode_tps || 0) } : m;
136
+ } catch (e) { best = { err: e.message }; break; }
137
+ }
138
+ RESULTS.push({ label: best.err ? label : T("{0} トークン", best.prompt.toLocaleString()), ...best }); show();
139
  }
140
+ $("status").textContent = T("完了"); $("run").disabled = false; $("copy").disabled = !RESULTS.some(r => !r.err);
141
  }
142
 
143
+ function markdown() {
144
+ const env = [[T("アプリ"), INFO.app], [T("モデル"), INFO.model], [T("デバイス"), Object.values(INFO.devices || {}).join(", ")],
145
+ [T("CPU"), INFO.cpu], [T("OS"), INFO.os], [T("条件"), T("温度 0・思考オフ・生成 {0} トークン・{1} 回の最良値", $("gen").value, $("reps").value)],
146
+ [T("測定日"), new Date().toLocaleDateString()]].filter(([, v]) => v);
147
+ const head = `| ${T("プロンプト")} | ${T("プリフィル")} | ${T("プリフィル速度")} | ${T("生成速度")} |\n|---|---:|---:|---:|`;
148
+ const body = RESULTS.filter(r => !r.err).map(r =>
149
+ `| ${r.label} | ${T("{0}秒", fmt(r.prefill_s, 2))} | ${fmt(r.prefill_tps, 0)} tok/s | ${fmt(r.decode_tps, 1)} tok/s |`).join("\n");
150
+ return env.map(([k, v]) => `- ${k}: ${v}`).join("\n") + "\n\n" + head + "\n" + body + "\n";
151
+ }
152
+
153
+ $("copy").onclick = async () => {
154
+ const text = markdown();
155
+ try { await navigator.clipboard.writeText(text); $("status").textContent = T("コピーしました"); }
156
+ catch { const a = document.createElement("textarea"); a.value = text; document.body.appendChild(a); a.select();
157
+ document.execCommand("copy"); a.remove(); $("status").textContent = T("コピーしました"); }
158
+ };
159
+
160
  fetch("/api/info").then(r => r.json()).then(i => {
161
  INFO = i;
162
+ document.title = $("h1").textContent = T("{0} ベンチマーク", (i.app || "").split(" ")[0] || "LLM");
163
+ const rows = [[T("アプリ"), i.app], [T("モデル名"), i.model], [T("デバイス"), Object.values(i.devices || {}).join(", ")],
164
+ [T("CPU"), i.cpu], [T("OS"), i.os], [T("コンテキスト長"), T("{0} トークン", i.context || i.max_context)],
165
+ [T("先読み(MTP・ドラフト)"), i.mtp ? T("有効") : T("なし")]].filter(([, v]) => v);
166
  $("info").innerHTML = rows.map(([k, v]) => `<tr><td>${esc(k)}</td><td>${esc(v)}</td></tr>`).join("");
167
  $("base").textContent = location.origin + "/v1"; $("mid").textContent = i.model;
168
+ const ctx = i.context || i.max_context || 0;
169
+ for (const c of document.querySelectorAll(".len")) if (ctx && +c.value + 512 > ctx) { c.checked = false; c.disabled = true; }
170
  }).catch(() => { $("info").innerHTML = `<tr><td class='bad'>${T("サーバーに接続できません")}</td></tr>`; });
171
  $("run").onclick = run;
172
  </script>
onw/web/index.html CHANGED
@@ -110,6 +110,7 @@
110
  <button id="sidebtn" title="会話の履歴">☰</button>
111
  <h1 id="title">NPU</h1>
112
  <span id="dev" class="badge">接続中…</span>
 
113
  <span class="spacer"></span>
114
  <button id="clear" title="会話をクリア">新しい会話</button>
115
  </header>
@@ -537,10 +538,26 @@ async function send() {
537
  return answer();
538
  }
539
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
540
  async function answer() { // an answer to the conversation as it stands (send, regenerate)
541
  let lastThink = ""; // (this answer's thinking, saved with it)
542
  const out = addMsg("assistant", "…");
543
- let acc = "", usage = null; const t0 = performance.now(); let first = 0;
544
  const mine = chat, ctl = new AbortController(); // (a new / another conversation may be opened meanwhile)
545
  controller = ctl; $("send").textContent = T("停止");
546
  let tm = 0, connected = false; // the answer is drawn at most every 100 ms (not on every token), and once at the end
@@ -592,6 +609,7 @@ async function answer() { // an answer to the conversation as it
592
  }
593
  if (j.choices?.[0]?.finish_reason) finish = j.choices[0].finish_reason;
594
  if (j.usage) usage = j.usage;
 
595
  }
596
  }
597
  if (!finish) throw new TypeError("cut off"); // (the stream ended without an end: the server went away)
@@ -613,6 +631,7 @@ async function answer() { // an answer to the conversation as it
613
  }
614
  if (acc && finish === "length") st += (st ? " · " : "") + T("最大トークンで打ち切り"); // (cut off by the token limit)
615
  if (st) { const s = document.createElement("div"); s.className = "stats"; s.textContent = st; out.bubble.appendChild(s); }
 
616
  mine.msgs.push({ role: "assistant", text: acc, reasoning: lastThink, stats: st });
617
  saveChat(mine);
618
  addTools(out, acc);
 
110
  <button id="sidebtn" title="会話の履歴">☰</button>
111
  <h1 id="title">NPU</h1>
112
  <span id="dev" class="badge">接続中…</span>
113
+ <span id="speed" class="badge ok" style="display:none"></span>
114
  <span class="spacer"></span>
115
  <button id="clear" title="会話をクリア">新しい会話</button>
116
  </header>
 
538
  return answer();
539
  }
540
 
541
+ // [bench] the last answer's speeds in the header: prompt reading (the tokens read anew / the server's prefill time)
542
+ // and generation (the server's decode tokens a second)
543
+ function showSpeed(usage, s) {
544
+ if (!usage || !s) return;
545
+ const fresh = (usage.prompt_tokens || 0) - (usage.prompt_tokens_details?.cached_tokens || 0);
546
+ const pre = (s.prefill_ms || 0) / 1000;
547
+ const parts = [];
548
+ if (fresh > 0 && pre > 0) parts.push(T("プリフィル {0} tok/s", Math.round(fresh / pre).toLocaleString()));
549
+ if (s.decode_tok_s) parts.push(T("生成 {0} tok/s", s.decode_tok_s.toFixed(1)));
550
+ const b = document.getElementById("speed");
551
+ b.textContent = parts.join(" · ");
552
+ b.title = T("直前の回答:プロンプト {0} トークン(新しく読んだ分 {1})を {2} 秒で読み、{3} トークンを生成",
553
+ usage.prompt_tokens, fresh, pre.toFixed(2), usage.completion_tokens);
554
+ b.style.display = parts.length ? "" : "none";
555
+ }
556
+
557
  async function answer() { // an answer to the conversation as it stands (send, regenerate)
558
  let lastThink = ""; // (this answer's thinking, saved with it)
559
  const out = addMsg("assistant", "…");
560
+ let acc = "", usage = null, speed = null; const t0 = performance.now(); let first = 0;
561
  const mine = chat, ctl = new AbortController(); // (a new / another conversation may be opened meanwhile)
562
  controller = ctl; $("send").textContent = T("停止");
563
  let tm = 0, connected = false; // the answer is drawn at most every 100 ms (not on every token), and once at the end
 
609
  }
610
  if (j.choices?.[0]?.finish_reason) finish = j.choices[0].finish_reason;
611
  if (j.usage) usage = j.usage;
612
+ if (j.onwx_stats || j.onw_stats) speed = j.onwx_stats || j.onw_stats; // [bench] the server's own timing
613
  }
614
  }
615
  if (!finish) throw new TypeError("cut off"); // (the stream ended without an end: the server went away)
 
631
  }
632
  if (acc && finish === "length") st += (st ? " · " : "") + T("最大トークンで打ち切り"); // (cut off by the token limit)
633
  if (st) { const s = document.createElement("div"); s.className = "stats"; s.textContent = st; out.bubble.appendChild(s); }
634
+ showSpeed(usage, speed);
635
  mine.msgs.push({ role: "assistant", text: acc, reasoning: lastThink, stats: st });
636
  saveChat(mine);
637
  addTools(out, acc);
onw/web/manage.html CHANGED
@@ -179,7 +179,7 @@
179
  <span class="muted small" id="sinfo"></span>
180
  <span class="grow"></span>
181
  <button id="b-chat" onclick="post('open',{what:'chat'})">チャット</button>
182
- <button id="b-check" onclick="post('open',{what:'check'})">検証ページ</button>
183
  <button onclick="post('open',{what:'log'})">ログ</button>
184
  </div>
185
  <div class="kv">
 
179
  <span class="muted small" id="sinfo"></span>
180
  <span class="grow"></span>
181
  <button id="b-chat" onclick="post('open',{what:'chat'})">チャット</button>
182
+ <button id="b-check" onclick="post('open',{what:'bench'})">ベンチマーク</button>
183
  <button onclick="post('open',{what:'log'})">ログ</button>
184
  </div>
185
  <div class="kv">