pi-fireworks-provider 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +24 -1
  2. package/index.ts +500 -3
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -19,7 +19,8 @@ _Kimi, MiniMax, GLM, DeepSeek, GPT-OSS — via Fireworks AI's Anthropic Messages
19
19
  - **Dual API support** via Fireworks AI's Anthropic Messages and OpenAI-compatible completions endpoints (per-model routing, matching pi core's Fireworks provider)
20
20
  - **Service tiers** — toggle Fireworks `priority` vs `standard` per request on supported models (with priority pricing reflected in cost tracking), via a keybinding, `/fireworks-tier`, and a footer status area
21
21
  - **Preserved thinking** — toggle Fireworks' `reasoning_history: "preserved"` so prior assistant reasoning is retained across turns (better multi-turn recall; uses more tokens), via the `/fireworks-settings` panel, with a model-select notification. Matches neuralwatt/makora's settings-only UX, adapted to Fireworks' single global `reasoning_history` knob
22
- - **Settings panel** — `/fireworks-settings` (TUI) to configure preserved thinking, service tier, and display preferences; persisted to `~/.pi/agent/extensions/fireworks.json`
22
+ - **Logit bias** — set an OpenAI-style `logit_bias` map (token ID → -100..100) via a nested `/fireworks-settings` panel (add / edit / delete arbitrary token IDs), sent on every Fireworks OpenAI-completions request; persisted to `~/.pi/agent/extensions/fireworks.json`
23
+ - **Settings panel** — `/fireworks-settings` (TUI) to configure preserved thinking, logit bias, service tier, and display preferences; persisted to `~/.pi/agent/extensions/fireworks.json`
23
24
  - **Cost Tracking** with per-model pricing for budget management
24
25
  - **Reasoning Models** support for advanced reasoning capabilities
25
26
  - **Vision Support** for image-capable models
@@ -149,6 +150,10 @@ The selection is persisted per session (survives `/reload` and resume). When `pr
149
150
  },
150
151
  "preserveThinking": {
151
152
  "default": false
153
+ },
154
+ "logitBias": {
155
+ "enabled": false,
156
+ "biases": {}
152
157
  }
153
158
  }
154
159
  ```
@@ -157,6 +162,8 @@ The selection is persisted per session (survives `/reload` and resume). When `pr
157
162
  - `serviceTier.keybinding` — any [pi key format](https://github.com/earendil-works/pi-coding-agent/blob/main/docs/keybindings.md) (e.g. `ctrl+shift+l`, `ctrl+shift+k`). Requires `/reload` after changing. On macOS browser terminals (localterm), avoid `alt`/`ctrl+alt` (Option produces special chars) and `ctrl+shift+t/w/n/c/v` (browser/localterm tab + copy/paste shortcuts).
158
163
  - `serviceTier.display` — `statusbar` (footer status area) or `off` (hide the tier indicator).
159
164
  - `preserveThinking.default` — whether `reasoning_history: "preserved"` is injected (`true` | `false`, default `false`). Also settable via `/fireworks-settings`.
165
+ - `logitBias.enabled` — whether the `logit_bias` map is sent on OpenAI-completions requests (`true` | `false`, default `false`). Also settable via `/fireworks-settings`.
166
+ - `logitBias.biases` — a map of token ID (string) → bias (integer -100..100), e.g. `{"123": 5, "456": -100}`. Editable via the nested `/fireworks-settings` → *Logit bias* panel. -100 effectively bans the token; other values shift its logit additively before sampling.
160
167
 
161
168
  > **Note:** The OpenAI completions endpoint accepts `service_tier` directly (per Fireworks' API). The Anthropic Messages endpoint passes the top-level field through as an extra. If a supported Anthropic-routed model rejects it, file an issue so we can gate injection by API.
162
169
 
@@ -175,6 +182,22 @@ Unlike neuralwatt/makora (which use per-model vLLM `chat_template_kwargs` flags
175
182
 
176
183
  Preserved thinking is **off by default** to match pi core and Fireworks' default (stripped). There is intentionally no `/fireworks-preserve` command or keybinding — it's settings-panel-only, mirroring neuralwatt/makora.
177
184
 
185
+ ## Logit Bias
186
+
187
+ Fireworks' OpenAI-compatible completions endpoint accepts the standard OpenAI `logit_bias` parameter: a map of token ID → bias (integer -100..100), added to the token's logit before sampling. `-100` effectively bans the token; other values shift its likelihood up or down. See [the Fireworks chat-completions API reference](https://docs.fireworks.ai/api-reference/post-chatcompletions).
188
+
189
+ This extension forwards the map verbatim as the top-level `logit_bias` field on every Fireworks OpenAI-completions request when `logitBias.enabled` is `true` and the map is non-empty.
190
+
191
+ **Important:** token IDs are **tokenizer-specific**. A token ID that biases one model correctly may be wrong for another (different vocabularies / tokenizers). You must compute token IDs against the exact tokenizer of the model you're calling — Fireworks uses each model's native tokenizer (Llama, Mistral, etc.). There's no per-tokenizer validation here; biasing a wrong ID silently does nothing (or worse, biases an unrelated token).
192
+
193
+ **OpenAI-completions only.** `logit_bias` is injected only on the OpenAI-completions transport. Models routed via the Anthropic Messages transport (`api: "anthropic-messages"`, only set via a patch/custom model — the provider default is `openai-completions`) are skipped, since the Anthropic Messages API has no `logit_bias` equivalent.
194
+
195
+ **Edit it:**
196
+
197
+ - **`/fireworks-settings`** (TUI) → *Logit bias* → open the nested panel. A search `>` area at the top filters entries by token ID (type digits); `↑↓` move · `Enter` activate (toggle *Enabled* / edit a bias / start *Add token…*) · `d` delete the selected entry · `Esc` back. Add / edit use free-text entry (token ID = non-negative integer, bias = -100..100). Changes take effect immediately and persist.
198
+
199
+ The top-level *Logit bias* row shows a one-line summary (e.g. `3 entries · on`, `off`). Off by default; an empty map (or `enabled: false`) means nothing is sent.
200
+
178
201
  ## Usage
179
202
 
180
203
  After loading the extension, use the `/model` command in pi to select your preferred model:
package/index.ts CHANGED
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import { getAgentDir, type ExtensionAPI, type ModelRegistry } from "@earendil-works/pi-coding-agent";
29
+ import type { Input, matchesKey, Key, truncateToWidth, visibleWidth, wrapTextWithAnsi, fuzzyFilter, SettingsListTheme } from "@earendil-works/pi-tui";
29
30
  import modelsData from "./models.json" with { type: "json" };
30
31
  import customModelsData from "./custom-models.json" with { type: "json" };
31
32
  import patchData from "./patch.json" with { type: "json" };
@@ -378,9 +379,21 @@ interface PreserveThinkingConfig {
378
379
  default: PreserveMode;
379
380
  }
380
381
 
382
+ // Logit bias: an OpenAI-style map of token ID → bias (-100..100), forwarded
383
+ // verbatim as the top-level `logit_bias` request field on Fireworks'
384
+ // OpenAI-compatible completions endpoint. Token IDs are tokenizer-specific
385
+ // (the caller must match the model's tokenizer). Disabled by default; an empty
386
+ // map or enabled=false means no injection. String-keyed so it serializes 1:1
387
+ // to the wire format.
388
+ interface LogitBiasConfig {
389
+ enabled: boolean;
390
+ biases: Record<string, number>;
391
+ }
392
+
381
393
  interface FireworksConfig {
382
394
  serviceTier: ServiceTierConfig;
383
395
  preserveThinking: PreserveThinkingConfig;
396
+ logitBias: LogitBiasConfig;
384
397
  }
385
398
 
386
399
  const FIREWORKS_CONFIG_PATH = path.join(getAgentDir(), "extensions", "fireworks.json");
@@ -398,9 +411,14 @@ const DEFAULT_SERVICE_TIER_CONFIG: ServiceTierConfig = {
398
411
  const DEFAULT_PRESERVE_CONFIG: PreserveThinkingConfig = {
399
412
  default: false,
400
413
  };
414
+ const DEFAULT_LOGIT_BIAS_CONFIG: LogitBiasConfig = {
415
+ enabled: false,
416
+ biases: {},
417
+ };
401
418
  const DEFAULT_FIREWORKS_CONFIG: FireworksConfig = {
402
419
  serviceTier: DEFAULT_SERVICE_TIER_CONFIG,
403
420
  preserveThinking: DEFAULT_PRESERVE_CONFIG,
421
+ logitBias: DEFAULT_LOGIT_BIAS_CONFIG,
404
422
  };
405
423
 
406
424
  function isValidTier(v: unknown): v is ServiceTier {
@@ -411,11 +429,63 @@ function isValidKeybinding(v: unknown): v is string {
411
429
  return typeof v === "string" && v.length > 0;
412
430
  }
413
431
 
432
+ // A valid logit-bias value: integer in [-100, 100]. -100 effectively bans the
433
+ // token (sets its logit to -inf on Fireworks / vLLM); other values shift the
434
+ // logit additively before sampling (per OpenAI / Fireworks semantics).
435
+ function isValidBiasValue(v: unknown): v is number {
436
+ return typeof v === "number" && Number.isInteger(v) && v >= -100 && v <= 100;
437
+ }
438
+
439
+ // Validate the logit_bias map (tokenId-string → bias). Keys must be pure
440
+ // non-negative-integer strings ("1", "007"); they're normalized to canonical
441
+ // "String(parseInt)" keys. Invalid keys / values are dropped silently so a
442
+ // malformed config can't crash model registration. Rejects "1.5", "-1", "abc".
443
+ function parseLogitBiasMap(raw: unknown): Record<string, number> {
444
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
445
+ const result: Record<string, number> = {};
446
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
447
+ if (!/^\d+$/.test(key)) continue;
448
+ const tokenId = Number.parseInt(key, 10);
449
+ if (!Number.isInteger(tokenId) || tokenId < 0) continue;
450
+ if (!isValidBiasValue(value)) continue;
451
+ result[String(tokenId)] = value;
452
+ }
453
+ return result;
454
+ }
455
+
456
+ // Validate the `logitBias` config object. Non-object / array → defaults.
457
+ function parseLogitBiasConfig(raw: unknown): LogitBiasConfig {
458
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
459
+ return { enabled: false, biases: {} };
460
+ }
461
+ const lb = raw as Record<string, unknown>;
462
+ return {
463
+ enabled: lb.enabled === true,
464
+ biases: parseLogitBiasMap(lb.biases),
465
+ };
466
+ }
467
+
468
+ // Strict integer parse for user-typed logit-bias inputs. Rejects decimals
469
+ // ("1.5"), signs ("+5"), empty, and non-numeric strings — Number.parseInt
470
+ // would silently truncate those, masking typos. Used by the TUI editor.
471
+ function parseTokenIdInput(raw: string): number | null {
472
+ if (!/^\d+$/.test(raw)) return null;
473
+ const n = Number.parseInt(raw, 10);
474
+ return Number.isInteger(n) && n >= 0 ? n : null;
475
+ }
476
+
477
+ function parseBiasInput(raw: string): number | null {
478
+ if (!/^-?\d+$/.test(raw)) return null;
479
+ const n = Number.parseInt(raw, 10);
480
+ return Number.isInteger(n) && n >= -100 && n <= 100 ? n : null;
481
+ }
482
+
414
483
  function loadFireworksConfig(): FireworksConfig {
415
484
  try {
416
485
  const raw = JSON.parse(fs.readFileSync(FIREWORKS_CONFIG_PATH, "utf8"));
417
486
  const st = raw?.serviceTier ?? {};
418
487
  const pt = raw?.preserveThinking ?? {};
488
+ const lb = raw?.logitBias ?? {};
419
489
  return {
420
490
  serviceTier: {
421
491
  default: isValidTier(st.default) ? st.default : DEFAULT_SERVICE_TIER_CONFIG.default,
@@ -425,6 +495,7 @@ function loadFireworksConfig(): FireworksConfig {
425
495
  preserveThinking: {
426
496
  default: typeof pt.default === "boolean" ? pt.default : DEFAULT_PRESERVE_CONFIG.default,
427
497
  },
498
+ logitBias: parseLogitBiasConfig(lb),
428
499
  };
429
500
  } catch {
430
501
  // Config missing or invalid — write defaults so the user can discover it.
@@ -434,7 +505,7 @@ function loadFireworksConfig(): FireworksConfig {
434
505
  } catch {
435
506
  // Write failure is non-fatal — defaults still work in memory.
436
507
  }
437
- return { serviceTier: { ...DEFAULT_SERVICE_TIER_CONFIG }, preserveThinking: { ...DEFAULT_PRESERVE_CONFIG } };
508
+ return { serviceTier: { ...DEFAULT_SERVICE_TIER_CONFIG }, preserveThinking: { ...DEFAULT_PRESERVE_CONFIG }, logitBias: { enabled: false, biases: {} } };
438
509
  }
439
510
  }
440
511
 
@@ -460,6 +531,33 @@ function writeRawFireworksConfig(raw: Record<string, any>): void {
460
531
 
461
532
  let fireworksConfig = loadFireworksConfig();
462
533
 
534
+ // Logit Bias state — read/refresh/mutate the logit_bias config. The in-memory
535
+ // cache (`fireworksConfig`) is refreshed on session_start and after every
536
+ // settings write (mutateLogitBias); before_provider_request reads it directly.
537
+ function getLogitBias(): LogitBiasConfig {
538
+ return fireworksConfig.logitBias;
539
+ }
540
+
541
+ // Re-read + refresh the in-memory config from disk so the settings editor sees
542
+ // the latest file state (handles hand-edits since session_start). Called when
543
+ // the logit-bias submenu opens (mirrors makora's "fresh state on each open").
544
+ function reloadLogitBias(): LogitBiasConfig {
545
+ fireworksConfig = loadFireworksConfig();
546
+ return fireworksConfig.logitBias;
547
+ }
548
+
549
+ // Read-modify-write the validated logit-bias config, then refresh the in-memory
550
+ // cache so before_provider_request picks up the change on the next request.
551
+ // The mutator receives a normalized LogitBiasConfig it can mutate in place.
552
+ function mutateLogitBias(mutator: (lb: LogitBiasConfig) => void): void {
553
+ const raw = readRawFireworksConfig();
554
+ const current = parseLogitBiasConfig(raw.logitBias);
555
+ mutator(current);
556
+ raw.logitBias = current;
557
+ writeRawFireworksConfig(raw);
558
+ fireworksConfig = loadFireworksConfig();
559
+ }
560
+
463
561
  // Held so module-scope helpers (setTier) can call pi.appendEntry.
464
562
  let piRef: ExtensionAPI | null = null;
465
563
 
@@ -587,6 +685,359 @@ function setPreserve(on: boolean): void {
587
685
  preserveOn = on;
588
686
  }
589
687
 
688
+ // Logit Bias Editor (TUI) — custom Component for the "Logit bias" submenu.
689
+ //
690
+ // The makora nested-UI idiom is SettingsList → submenu → SettingsList, but
691
+ // SettingsList only cycles fixed `values` or opens a sub-Component — it can't
692
+ // take free text, and "add an arbitrary token ID + bias" needs free text. So
693
+ // this editor IS the submenu Component: it owns an ordered entry list (add /
694
+ // edit / delete mutate it in place and re-render without closing the submenu)
695
+ // and uses pi-tui's Input for the token-ID and bias prompts. Every mutation is
696
+ // persisted immediately via mutateLogitBias; Esc returns to the parent settings
697
+ // list, passing back a one-line summary so the top-level row's value refreshes.
698
+
699
+ type LogitBiasEntry = { tokenId: number; bias: number };
700
+
701
+ type Row = { label: string; value: string; description: string; kind: "enabled" | "entry" | "add"; tokenId?: number };
702
+
703
+ interface LogitBiasEditorDeps {
704
+ InputCtor: typeof Input;
705
+ matchesKey: typeof matchesKey;
706
+ Key: typeof Key;
707
+ truncateToWidth: typeof truncateToWidth;
708
+ visibleWidth: typeof visibleWidth;
709
+ wrapTextWithAnsi: typeof wrapTextWithAnsi;
710
+ fuzzyFilter: typeof fuzzyFilter;
711
+ settingsListTheme: SettingsListTheme;
712
+ theme: { fg(name: string, text: string): string };
713
+ notify: (msg: string, level: "info" | "error") => void;
714
+ subDone: (value?: string) => void;
715
+ }
716
+
717
+ class LogitBiasEditor {
718
+ private entries: LogitBiasEntry[];
719
+ private enabled: boolean;
720
+ private selectedIndex = 0;
721
+ private mode: "list" | "addToken" | "addBias" | "editBias" = "list";
722
+ private input: Input;
723
+ // Search `>` area at the top of the panel (matches SettingsList's search).
724
+ // Filters rows by label via fuzzyFilter; queries are numeric (token IDs), so
725
+ // `d` is reserved as a delete shortcut when the query is empty.
726
+ private searchInput: Input;
727
+ private searchQuery = "";
728
+ private pendingTokenId: number | null = null;
729
+ private editingTokenId: number | null = null;
730
+ private readonly maxVisible = 10;
731
+
732
+ constructor(private deps: LogitBiasEditorDeps) {
733
+ const lb = reloadLogitBias();
734
+ this.enabled = lb.enabled;
735
+ this.entries = LogitBiasEditor.sorted(lb.biases);
736
+ this.input = new deps.InputCtor();
737
+ this.input.onSubmit = () => this.submitInput();
738
+ this.input.onEscape = () => this.cancelInput();
739
+ this.searchInput = new deps.InputCtor();
740
+ }
741
+
742
+ private static sorted(biases: Record<string, number>): LogitBiasEntry[] {
743
+ return Object.entries(biases)
744
+ .map(([k, bias]) => ({ tokenId: Number.parseInt(k, 10), bias }))
745
+ .sort((a, b) => a.tokenId - b.tokenId);
746
+ }
747
+
748
+ private persist(): void {
749
+ const biases: Record<string, number> = {};
750
+ for (const e of this.entries) biases[String(e.tokenId)] = e.bias;
751
+ mutateLogitBias((lb) => {
752
+ lb.enabled = this.enabled;
753
+ lb.biases = biases;
754
+ });
755
+ }
756
+
757
+ private summary(): string {
758
+ const n = this.entries.length;
759
+ if (n === 0) return this.enabled ? "on · empty" : "off";
760
+ return `${n} ${n === 1 ? "entry" : "entries"} · ${this.enabled ? "on" : "off"}`;
761
+ }
762
+
763
+ // Virtual rows: [Enabled toggle] + entries + [Add token…].
764
+ private buildRows(): Row[] {
765
+ const rows: Row[] = [];
766
+ rows.push({
767
+ label: "Enabled",
768
+ value: this.enabled ? "on" : "off",
769
+ kind: "enabled",
770
+ description: "When on, the logit_bias map is sent on every Fireworks OpenAI-completions request, biasing the listed token IDs by their bias (-100..100). Off = nothing sent (entries are kept). Token IDs are tokenizer-specific — match the model's tokenizer.",
771
+ });
772
+ for (const e of this.entries) {
773
+ rows.push({
774
+ label: `token ${e.tokenId}`,
775
+ value: String(e.bias),
776
+ kind: "entry",
777
+ tokenId: e.tokenId,
778
+ description: `token ${e.tokenId} → ${e.bias}. Enter to edit the bias (-100..100), d to delete. Token IDs are tokenizer-specific.`,
779
+ });
780
+ }
781
+ rows.push({
782
+ label: "Add token…",
783
+ value: "",
784
+ kind: "add",
785
+ description: "Add a new entry: prompts for the token ID (non-negative integer), then the bias (-100..100).",
786
+ });
787
+ return rows;
788
+ }
789
+
790
+ // Rows visible after the search filter. With no query, returns all rows.
791
+ private filteredRows(): Row[] {
792
+ const rows = this.buildRows();
793
+ if (!this.searchQuery) return rows;
794
+ return this.deps.fuzzyFilter(rows, this.searchQuery, (r) => r.label);
795
+ }
796
+
797
+ private applyFilter(): void {
798
+ this.searchQuery = this.searchInput.getValue();
799
+ this.selectedIndex = 0;
800
+ }
801
+
802
+ private clearSearch(): void {
803
+ this.searchInput.setValue("");
804
+ this.searchQuery = "";
805
+ }
806
+
807
+ handleInput(data: string): void {
808
+ if (this.mode !== "list") {
809
+ this.input.handleInput(data);
810
+ return;
811
+ }
812
+ const { matchesKey, Key } = this.deps;
813
+ if (matchesKey(data, Key.up)) {
814
+ const n = this.filteredRows().length;
815
+ if (n > 0) this.selectedIndex = (this.selectedIndex - 1 + n) % n;
816
+ } else if (matchesKey(data, Key.down)) {
817
+ const n = this.filteredRows().length;
818
+ if (n > 0) this.selectedIndex = (this.selectedIndex + 1) % n;
819
+ } else if (matchesKey(data, Key.enter) || data === " ") {
820
+ this.activateSelected();
821
+ } else if (matchesKey(data, Key.escape)) {
822
+ this.close();
823
+ } else if (this.searchQuery === "" && (data === "d" || data === "D")) {
824
+ // `d` deletes the selected entry. Only active when not searching — with a
825
+ // query active, `d` is routed to the search input (token IDs are numeric,
826
+ // so `d` is never a useful search term, but routing avoids accidental
827
+ // deletes mid-search).
828
+ this.deleteSelected();
829
+ } else {
830
+ // Route everything else (printables, backspace, cursor arrows) to the
831
+ // search input, mirroring SettingsList. Spaces are dropped (sanitized)
832
+ // so they never enter the query.
833
+ const sanitized = data.replace(/ /g, "");
834
+ if (!sanitized) return;
835
+ this.searchInput.handleInput(sanitized);
836
+ this.applyFilter();
837
+ }
838
+ }
839
+
840
+ private activateSelected(): void {
841
+ const row = this.filteredRows()[this.selectedIndex];
842
+ if (!row) return;
843
+ if (row.kind === "enabled") {
844
+ this.toggleEnabled();
845
+ } else if (row.kind === "add") {
846
+ this.startAddToken();
847
+ } else if (row.kind === "entry" && row.tokenId !== undefined) {
848
+ const entry = this.entries.find((e) => e.tokenId === row.tokenId);
849
+ if (entry) this.startEditBias(entry);
850
+ }
851
+ }
852
+
853
+ private toggleEnabled(): void {
854
+ this.enabled = !this.enabled;
855
+ this.persist();
856
+ this.deps.notify(`Logit bias ${this.enabled ? "on" : "off"}.`, "info");
857
+ }
858
+
859
+ private deleteSelected(): void {
860
+ const row = this.filteredRows()[this.selectedIndex];
861
+ if (!row || row.kind !== "entry" || row.tokenId === undefined) return;
862
+ const idx = this.entries.findIndex((e) => e.tokenId === row.tokenId);
863
+ if (idx < 0) return;
864
+ const [removed] = this.entries.splice(idx, 1);
865
+ this.persist();
866
+ const newLen = this.filteredRows().length;
867
+ if (this.selectedIndex >= newLen) this.selectedIndex = Math.max(0, newLen - 1);
868
+ if (removed) this.deps.notify(`Removed token ${removed.tokenId}.`, "info");
869
+ }
870
+
871
+ private startAddToken(): void {
872
+ this.clearSearch();
873
+ this.mode = "addToken";
874
+ this.pendingTokenId = null;
875
+ this.input.setValue("");
876
+ this.input.focused = true;
877
+ }
878
+
879
+ private startEditBias(entry: LogitBiasEntry): void {
880
+ this.clearSearch();
881
+ // Re-select the entry in the (now unfiltered) full list so the cursor
882
+ // stays on it after returning from the bias prompt.
883
+ const fullIdx = this.buildRows().findIndex((r) => r.kind === "entry" && r.tokenId === entry.tokenId);
884
+ this.selectedIndex = fullIdx >= 0 ? fullIdx : 0;
885
+ this.mode = "editBias";
886
+ this.editingTokenId = entry.tokenId;
887
+ this.input.setValue(String(entry.bias));
888
+ this.input.focused = true;
889
+ }
890
+
891
+ private submitInput(): void {
892
+ const raw = this.input.getValue().trim();
893
+ if (this.mode === "addToken") {
894
+ const tokenId = parseTokenIdInput(raw);
895
+ if (tokenId === null) {
896
+ this.deps.notify("Token ID must be a non-negative integer.", "error");
897
+ return;
898
+ }
899
+ if (this.entries.some((e) => e.tokenId === tokenId)) {
900
+ this.deps.notify(`Token ${tokenId} already has a bias — edit it instead.`, "error");
901
+ return;
902
+ }
903
+ this.pendingTokenId = tokenId;
904
+ this.mode = "addBias";
905
+ this.input.setValue("0");
906
+ this.input.focused = true;
907
+ return;
908
+ }
909
+ if (this.mode === "addBias") {
910
+ const bias = parseBiasInput(raw);
911
+ if (bias === null) {
912
+ this.deps.notify("Bias must be an integer from -100 to 100.", "error");
913
+ return;
914
+ }
915
+ const tokenId = this.pendingTokenId!;
916
+ this.entries.push({ tokenId, bias });
917
+ this.entries.sort((a, b) => a.tokenId - b.tokenId);
918
+ this.persist();
919
+ const newIdx = this.entries.findIndex((e) => e.tokenId === tokenId);
920
+ this.selectedIndex = 1 + newIdx;
921
+ this.pendingTokenId = null;
922
+ this.mode = "list";
923
+ this.input.focused = false;
924
+ this.deps.notify(`Added token ${tokenId} → ${bias}.`, "info");
925
+ return;
926
+ }
927
+ if (this.mode === "editBias") {
928
+ const bias = parseBiasInput(raw);
929
+ if (bias === null) {
930
+ this.deps.notify("Bias must be an integer from -100 to 100.", "error");
931
+ return;
932
+ }
933
+ const tokenId = this.editingTokenId!;
934
+ const entry = this.entries.find((e) => e.tokenId === tokenId);
935
+ if (entry) {
936
+ entry.bias = bias;
937
+ this.persist();
938
+ }
939
+ this.editingTokenId = null;
940
+ this.mode = "list";
941
+ this.input.focused = false;
942
+ this.deps.notify(`Set token ${tokenId} → ${bias}.`, "info");
943
+ return;
944
+ }
945
+ }
946
+
947
+ private cancelInput(): void {
948
+ this.mode = "list";
949
+ this.pendingTokenId = null;
950
+ this.editingTokenId = null;
951
+ this.input.focused = false;
952
+ }
953
+
954
+ private close(): void {
955
+ this.input.focused = false;
956
+ this.searchInput.focused = false;
957
+ this.deps.subDone(this.summary());
958
+ }
959
+
960
+ private renderList(width: number): string[] {
961
+ const t = this.deps.settingsListTheme;
962
+ const { truncateToWidth, visibleWidth, wrapTextWithAnsi } = this.deps;
963
+ const lines: string[] = [];
964
+ // Search `>` area — mirrors SettingsList (renderMainList) so the panel stays
965
+ // visually consistent with the rest of /fireworks-settings.
966
+ lines.push(...this.searchInput.render(width));
967
+ lines.push("");
968
+
969
+ const rows = this.filteredRows();
970
+ const total = rows.length;
971
+ if (total === 0) {
972
+ lines.push(t.hint(truncateToWidth(" No matching tokens", width)));
973
+ } else {
974
+ const maxLabel = Math.min(30, Math.max(...rows.map((r) => visibleWidth(r.label)), 1));
975
+ const start = Math.max(0, Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), total - this.maxVisible));
976
+ const end = Math.min(start + this.maxVisible, total);
977
+ for (let i = start; i < end; i++) {
978
+ const row = rows[i];
979
+ const selected = i === this.selectedIndex;
980
+ const prefix = selected ? t.cursor : " ";
981
+ const labelPadded = row.label + " ".repeat(Math.max(0, maxLabel - visibleWidth(row.label)));
982
+ const label = t.label(labelPadded, selected);
983
+ const sep = " ";
984
+ const valueWidth = width - visibleWidth(prefix) - maxLabel - visibleWidth(sep) - 2;
985
+ const value = row.value ? t.value(truncateToWidth(row.value, Math.max(0, valueWidth), ""), selected) : "";
986
+ lines.push(truncateToWidth(prefix + label + sep + value, width));
987
+ }
988
+ if (start > 0 || end < total) {
989
+ lines.push(t.hint(truncateToWidth(` (${this.selectedIndex + 1}/${total})`, width - 2, "")));
990
+ }
991
+ const sel = rows[this.selectedIndex];
992
+ if (sel?.description) {
993
+ lines.push("");
994
+ for (const ln of wrapTextWithAnsi(sel.description, width - 4)) {
995
+ lines.push(t.description(` ${ln}`));
996
+ }
997
+ }
998
+ }
999
+
1000
+ lines.push("");
1001
+ lines.push(truncateToWidth(t.hint(" Type to search · ↑↓ move · Enter activate · d delete · Esc back"), width));
1002
+ return lines;
1003
+ }
1004
+
1005
+ private renderInput(width: number): string[] {
1006
+ const { settingsListTheme: t, theme, truncateToWidth } = this.deps;
1007
+ const lines: string[] = [];
1008
+ let prompt = "";
1009
+ if (this.mode === "addToken") {
1010
+ prompt = "Token ID (non-negative integer):";
1011
+ } else if (this.mode === "addBias") {
1012
+ prompt = `Bias for token ${this.pendingTokenId} (-100..100):`;
1013
+ } else if (this.mode === "editBias") {
1014
+ prompt = `New bias for token ${this.editingTokenId} (-100..100):`;
1015
+ }
1016
+ lines.push(truncateToWidth(theme.fg("accent", prompt), width));
1017
+ lines.push(...this.input.render(width));
1018
+ lines.push("");
1019
+ lines.push(truncateToWidth(t.hint(" Enter submit · Esc cancel"), width));
1020
+ return lines;
1021
+ }
1022
+
1023
+ render(width: number): string[] {
1024
+ return this.mode === "list" ? this.renderList(width) : this.renderInput(width);
1025
+ }
1026
+
1027
+ invalidate(): void {
1028
+ this.input.invalidate?.();
1029
+ this.searchInput.invalidate?.();
1030
+ }
1031
+ }
1032
+
1033
+ // One-line summary of the logit-bias config for the top-level settings row.
1034
+ function logitBiasSummary(): string {
1035
+ const lb = getLogitBias();
1036
+ const n = Object.keys(lb.biases).length;
1037
+ if (n === 0) return lb.enabled ? "on · empty" : "off";
1038
+ return `${n} ${n === 1 ? "entry" : "entries"} · ${lb.enabled ? "on" : "off"}`;
1039
+ }
1040
+
590
1041
  // ─── Extension Entry Point ────────────────────────────────────────────────────
591
1042
 
592
1043
  export {
@@ -613,6 +1064,16 @@ export {
613
1064
  updateTierStatus,
614
1065
  isPreserveEligible,
615
1066
  setPreserve,
1067
+ isValidBiasValue,
1068
+ parseLogitBiasMap,
1069
+ parseLogitBiasConfig,
1070
+ parseBiasInput,
1071
+ parseTokenIdInput,
1072
+ getLogitBias,
1073
+ reloadLogitBias,
1074
+ mutateLogitBias,
1075
+ logitBiasSummary,
1076
+ LogitBiasEditor,
616
1077
  };
617
1078
 
618
1079
  export type {
@@ -624,6 +1085,8 @@ export type {
624
1085
  ServiceTierConfig,
625
1086
  PreserveThinkingConfig,
626
1087
  FireworksConfig,
1088
+ LogitBiasConfig,
1089
+ LogitBiasEntry,
627
1090
  };
628
1091
 
629
1092
  export default function (pi: ExtensionAPI) {
@@ -731,6 +1194,20 @@ export default function (pi: ExtensionAPI) {
731
1194
  modified = true;
732
1195
  }
733
1196
 
1197
+ // Logit bias: forward the user's logit_bias map (token ID → -100..100) on
1198
+ // Fireworks OpenAI-completions requests. The map is OpenAI-compatible and
1199
+ // Fireworks adds each bias to the token's logit before sampling. Gated to
1200
+ // the OpenAI-completions transport — the Anthropic Messages API has no
1201
+ // logit_bias equivalent, so we skip models routed via `api:
1202
+ // "anthropic-messages"` (the provider default is "openai-completions"; only
1203
+ // a patched/custom model sets the Anthropic transport). Token IDs are
1204
+ // tokenizer-specific — the caller must match the model's tokenizer.
1205
+ const lb = fireworksConfig.logitBias;
1206
+ if (lb.enabled && Object.keys(lb.biases).length > 0 && model.api !== "anthropic-messages") {
1207
+ payload.logit_bias = lb.biases;
1208
+ modified = true;
1209
+ }
1210
+
734
1211
  // Kimi anchor-bleed sanitization (Kimi K2.x pattern bug). Only applies to
735
1212
  // Kimi models, but a single request can be both Kimi and priority-tiered.
736
1213
  if (isFireworksKimiModel(model)) {
@@ -838,13 +1315,13 @@ export default function (pi: ExtensionAPI) {
838
1315
  // settings-only (no command/keybinding), exactly like the siblings; the
839
1316
  // service-tier keybinding is load-time only, so keybinding changes need /reload.
840
1317
  pi.registerCommand("fireworks-settings", {
841
- description: "Configure Fireworks: preserved thinking + service tier + display",
1318
+ description: "Configure Fireworks: preserved thinking + service tier + logit bias + display",
842
1319
  async handler(_args, ctx) {
843
1320
  if (ctx.mode !== "tui") {
844
1321
  ctx.ui.notify("/fireworks-settings requires TUI mode.", "error");
845
1322
  return;
846
1323
  }
847
- const { SettingsList, Container } = await import("@earendil-works/pi-tui");
1324
+ const { SettingsList, Container, Input, matchesKey, Key, truncateToWidth, visibleWidth, wrapTextWithAnsi, fuzzyFilter } = await import("@earendil-works/pi-tui");
848
1325
  const { getSettingsListTheme, DynamicBorder } = await import("@earendil-works/pi-coding-agent");
849
1326
 
850
1327
  await ctx.ui.custom((_tui, theme, _kb, done) => {
@@ -872,6 +1349,26 @@ export default function (pi: ExtensionAPI) {
872
1349
  currentValue: fireworksConfig.serviceTier.display,
873
1350
  values: ["statusbar", "off"],
874
1351
  },
1352
+ {
1353
+ id: "logitBias",
1354
+ label: "Logit bias",
1355
+ description: "Send an OpenAI-style logit_bias map (token ID → -100..100) on every Fireworks OpenAI-completions request. Open the nested panel to add / edit / delete token IDs and biases. Token IDs are tokenizer-specific — use the exact tokenizer of the model you're calling. Not sent on Anthropic-routed models.",
1356
+ currentValue: logitBiasSummary(),
1357
+ submenu: (_cv: string, subDone: (v?: string) => void) =>
1358
+ new LogitBiasEditor({
1359
+ InputCtor: Input,
1360
+ matchesKey,
1361
+ Key,
1362
+ truncateToWidth,
1363
+ visibleWidth,
1364
+ wrapTextWithAnsi,
1365
+ fuzzyFilter,
1366
+ settingsListTheme: getSettingsListTheme(),
1367
+ theme,
1368
+ notify: (msg, level) => { try { ctx.ui.notify(msg, level); } catch { /* notify is a no-op without a UI runner */ } },
1369
+ subDone,
1370
+ }),
1371
+ },
875
1372
  ];
876
1373
 
877
1374
  const container = new Container();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fireworks-provider",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Fireworks AI provider extension for pi - Access Kimi, MiniMax, GLM, DeepSeek, and GPT-OSS models through the Fireworks AI API",
5
5
  "type": "module",
6
6
  "main": "index.ts",