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 +4 -0
- CHANGELOG_en.md +4 -0
- CONVERT_LFM.md +4 -4
- CONVERT_LFM_en.md +4 -4
- MANUAL.md +6 -9
- MANUAL_en.md +6 -10
- README.md +3 -3
- README_en.md +4 -4
- onw/__init__.py +1 -1
- onw/i18n.json +61 -3
- onw/manager.py +2 -2
- onw/server.py +975 -900
- onw/tray.py +1 -1
- onw/web/{check.html → bench.html} +101 -63
- onw/web/index.html +20 -1
- onw/web/manage.html +1 -1
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 |
-
-
|
| 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`、出力フォルダのファイル、
|
| 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 |
-
-
|
| 121 |
-
above (
|
| 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,
|
| 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
|
|
|
|
| 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 |
-
|
| 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 では出ない質問でも起きます)。チャット画面
|
| 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
|
| 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
|
| 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 /
|
| 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
|
| 141 |
|
| 142 |
-
|
| 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
|
| 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 |
-
|
| 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`)、
|
| 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
|
| 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
|
| 174 |
-
prompt
|
| 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`),
|
| 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.
|
|
|
|
| 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 /
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
(
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
import
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
from fastapi
|
| 39 |
-
from
|
| 40 |
-
from
|
| 41 |
-
from
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
if
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
put(
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
except
|
| 327 |
-
raise
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
return
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
return
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
if lvl
|
| 393 |
-
return
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
if mode == "
|
| 404 |
-
return
|
| 405 |
-
if mode == "
|
| 406 |
-
return {k:
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
mx =
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
if
|
| 429 |
-
think =
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
"
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
"
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
if
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("
|
| 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>
|
| 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 |
-
.
|
| 26 |
-
.
|
| 27 |
#info td:first-child { color: var(--muted); width: 180px; }
|
|
|
|
|
|
|
|
|
|
| 28 |
</style>
|
| 29 |
</head>
|
| 30 |
<body>
|
| 31 |
<main>
|
| 32 |
-
<h1
|
| 33 |
-
<p class="sub">このサーバー
|
| 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 |
-
<
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
<span id="status" class="sub" style="margin-left:10px"></span>
|
| 44 |
<table style="margin-top:12px">
|
| 45 |
-
<thead><tr><th>
|
| 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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
|
|
| 59 |
|
| 60 |
async function api(messages, max_tokens) {
|
| 61 |
-
// thinking at its lowest ("off";
|
| 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,
|
| 68 |
-
reasoning_effort: "off" })
|
| 69 |
});
|
| 70 |
if (!r.ok) throw new Error((await r.text()).slice(0, 200));
|
| 71 |
-
|
| 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 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
| 92 |
}
|
| 93 |
|
| 94 |
-
function
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 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 |
-
|
| 106 |
-
const
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
}
|
| 120 |
-
$("status").textContent = T("完了"); $("run").disabled = false;
|
| 121 |
}
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
fetch("/api/info").then(r => r.json()).then(i => {
|
| 124 |
INFO = i;
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
[T("
|
| 128 |
-
[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 |
-
|
|
|
|
| 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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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:'
|
| 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">
|