@oh-my-pi/pi-ai 16.1.20 → 16.1.22
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.
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.1.22] - 2026-06-26
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed llama.cpp / LM Studio / vLLM (and any local OpenAI-compatible server on a loopback or RFC1918 baseUrl) re-processing the full prompt on every assistant continuation when the prior turn produced `reasoning_content`: the `openai-completions` encoder dropped the preserved `thinking` block on re-serialization for compat profiles without `requiresReasoningContentForToolCalls` / `thinkingFormat: "zai"`, so the chat template re-rendered the assistant turn without `<think>…</think>` and the rendered tokens diverged from the slot's KV cache state. The auto-learn capture-at-stop nudge made it reproduce on every turn. The encoder now replays preserved thinking as `reasoning_content` (honoring the streamed signature when it identifies a recognized wire field — `reasoning_content` / `reasoning` / `reasoning_text` — and falling back to the configured `reasoningContentField` for opaque signatures) whenever the new `compat.replayReasoningContent` flag is set, and the cross-API `transformMessages` predicate (`openAICompletionsReplaysUnsignedThinking`) honors the same flag ahead of the `model.reasoning` gate so a switch into a discovered local target (where the spec carries `reasoning: false` because the upstream `/models` endpoints don't advertise the capability) still preserves the prior turn's thinking block as signature-stripped reasoning instead of demoting it to conversation text. The chat-template-rendered prefix stays byte-stable across turns and llama.cpp's prefix KV cache survives. ([#3528](https://github.com/can1357/oh-my-pi/issues/3528))
|
|
10
|
+
|
|
11
|
+
## [16.1.21] - 2026-06-26
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Restored the `pollOAuthDeviceCodeFlow` export from `@oh-my-pi/pi-ai/oauth` so legacy provider extensions can reuse the host OAuth device-code poller. ([#3508](https://github.com/can1357/oh-my-pi/issues/3508))
|
|
16
|
+
|
|
5
17
|
## [16.1.20] - 2026-06-25
|
|
6
18
|
|
|
7
19
|
### Fixed
|
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
import type { OAuthCredentials, OAuthProvider, OAuthProviderId, OAuthProviderInfo, OAuthProviderInterface } from "./types";
|
|
2
2
|
export type * from "./types";
|
|
3
|
+
/** Result returned by one OAuth device-code polling attempt. */
|
|
4
|
+
export type OAuthDeviceCodePollResult<T> = {
|
|
5
|
+
status: "complete";
|
|
6
|
+
value: T;
|
|
7
|
+
} | {
|
|
8
|
+
status: "pending";
|
|
9
|
+
} | {
|
|
10
|
+
status: "slow_down";
|
|
11
|
+
} | {
|
|
12
|
+
status: "failed";
|
|
13
|
+
message: string;
|
|
14
|
+
};
|
|
15
|
+
/** Options for polling an RFC 8628-style OAuth device-code flow. */
|
|
16
|
+
export interface OAuthDeviceCodeFlowOptions<T> {
|
|
17
|
+
/** Poll the provider once and classify the response. */
|
|
18
|
+
poll(): OAuthDeviceCodePollResult<T> | Promise<OAuthDeviceCodePollResult<T>>;
|
|
19
|
+
/** Provider-requested polling cadence; defaults to RFC 8628's five seconds. */
|
|
20
|
+
intervalSeconds?: number;
|
|
21
|
+
/** Provider-issued expiry window for the device code. */
|
|
22
|
+
expiresInSeconds?: number;
|
|
23
|
+
/** Cancels the flow with the legacy "Login cancelled" error. */
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
/** Poll an OAuth device-code flow until completion, provider failure, timeout, or cancellation. */
|
|
27
|
+
export declare function pollOAuthDeviceCodeFlow<T>(options: OAuthDeviceCodeFlowOptions<T>): Promise<T>;
|
|
3
28
|
/**
|
|
4
29
|
* Register a custom OAuth provider.
|
|
5
30
|
*/
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "16.1.
|
|
4
|
+
"version": "16.1.22",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.0",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "16.1.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.1.
|
|
43
|
-
"@oh-my-pi/pi-wire": "16.1.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "16.1.22",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.1.22",
|
|
43
|
+
"@oh-my-pi/pi-wire": "16.1.22",
|
|
44
44
|
"arktype": "^2.2.0",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
|
@@ -1805,6 +1805,29 @@ export function convertMessages(
|
|
|
1805
1805
|
// signature on cross-API replays before the block reaches us.
|
|
1806
1806
|
const reasoningField = compat.reasoningContentField ?? "reasoning_content";
|
|
1807
1807
|
assistantMsg[reasoningField] = nonEmptyThinkingBlocks.map(b => b.thinking).join("\n");
|
|
1808
|
+
} else if (compat.replayReasoningContent) {
|
|
1809
|
+
// Local llama.cpp-style servers (llama.cpp, LM Studio, vLLM, Ollama
|
|
1810
|
+
// in openai-completions mode, custom providers pointed at a
|
|
1811
|
+
// loopback baseUrl) re-tokenize the entire prompt every request.
|
|
1812
|
+
// Qwen3 / DeepSeek-R1 / GLM chat templates reconstruct the prior
|
|
1813
|
+
// assistant turn's `<think>` block from `reasoning_content`; if we
|
|
1814
|
+
// drop the field the template re-renders the assistant turn
|
|
1815
|
+
// without thinking content, the rendered tokens diverge from the
|
|
1816
|
+
// slot's existing KV cache, and llama.cpp falls back to full
|
|
1817
|
+
// prompt re-processing (#3528). Honor the streamed signature when
|
|
1818
|
+
// it identifies a recognized wire field so a model that emitted
|
|
1819
|
+
// `reasoning` (some llama.cpp builds) round-trips to the same
|
|
1820
|
+
// field; otherwise fall back to the configured
|
|
1821
|
+
// `reasoningContentField`. Gated by the new compat flag rather
|
|
1822
|
+
// than the existing `requires*` flags because local servers
|
|
1823
|
+
// accept but don't validate the field — they just need it to
|
|
1824
|
+
// preserve cache locality.
|
|
1825
|
+
const signature = nonEmptyThinkingBlocks[0].thinkingSignature;
|
|
1826
|
+
const reasoningField: OpenAICompletionsReasoningField =
|
|
1827
|
+
signature === "reasoning_content" || signature === "reasoning" || signature === "reasoning_text"
|
|
1828
|
+
? signature
|
|
1829
|
+
: (compat.reasoningContentField ?? "reasoning_content");
|
|
1830
|
+
assistantMsg[reasoningField] = nonEmptyThinkingBlocks.map(b => b.thinking).join("\n");
|
|
1808
1831
|
}
|
|
1809
1832
|
}
|
|
1810
1833
|
|
|
@@ -253,9 +253,20 @@ function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messa
|
|
|
253
253
|
* branch.
|
|
254
254
|
*/
|
|
255
255
|
function openAICompletionsReplaysUnsignedThinking(model: Model, compat: Model["compat"]): boolean {
|
|
256
|
-
if (model.api !== "openai-completions"
|
|
256
|
+
if (model.api !== "openai-completions") return false;
|
|
257
257
|
if (compat === undefined || !("requiresReasoningContentForToolCalls" in compat)) return false;
|
|
258
258
|
if (compat.requiresThinkingAsText) return false;
|
|
259
|
+
// Local llama.cpp-style servers (`replayReasoningContent`) need the replay
|
|
260
|
+
// for KV-cache prefix reuse — Qwen3 / DeepSeek-R1 / GLM chat templates
|
|
261
|
+
// reconstruct the prior turn's `<think>` block from `reasoning_content`
|
|
262
|
+
// (#3528). Checked BEFORE the `model.reasoning` gate: the runtime discovery
|
|
263
|
+
// paths for `llama.cpp` / `lm-studio` / `openai-models-list` hardcode
|
|
264
|
+
// `reasoning: false` even when the upstream actually emits reasoning, so
|
|
265
|
+
// gating on the spec flag here would let a cross-API switch into such a
|
|
266
|
+
// target demote the prior `thinking` block to text and lose the
|
|
267
|
+
// cache-stable prefix `replayReasoningContent` is meant to preserve.
|
|
268
|
+
if (compat.replayReasoningContent) return true;
|
|
269
|
+
if (!model.reasoning) return false;
|
|
259
270
|
// Hosts that REQUIRE `reasoning_content` on tool-call turns (DeepSeek
|
|
260
271
|
// reasoning, Kimi, OpenRouter reasoning, OpenCode thinking-on) already
|
|
261
272
|
// accept the replay; Z.AI-format hosts (Z.AI, Zhipu, Moonshot Kimi native,
|
|
@@ -13,6 +13,94 @@ import type {
|
|
|
13
13
|
|
|
14
14
|
export type * from "./types";
|
|
15
15
|
|
|
16
|
+
const DEVICE_FLOW_CANCEL_MESSAGE = "Login cancelled";
|
|
17
|
+
const DEVICE_FLOW_TIMEOUT_MESSAGE = "Device flow timed out";
|
|
18
|
+
const DEVICE_FLOW_SLOW_DOWN_TIMEOUT_MESSAGE =
|
|
19
|
+
"Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.";
|
|
20
|
+
const MINIMUM_DEVICE_FLOW_INTERVAL_MS = 1000;
|
|
21
|
+
const DEFAULT_DEVICE_FLOW_INTERVAL_SECONDS = 5;
|
|
22
|
+
const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000;
|
|
23
|
+
|
|
24
|
+
/** Result returned by one OAuth device-code polling attempt. */
|
|
25
|
+
export type OAuthDeviceCodePollResult<T> =
|
|
26
|
+
| { status: "complete"; value: T }
|
|
27
|
+
| { status: "pending" }
|
|
28
|
+
| { status: "slow_down" }
|
|
29
|
+
| { status: "failed"; message: string };
|
|
30
|
+
|
|
31
|
+
/** Options for polling an RFC 8628-style OAuth device-code flow. */
|
|
32
|
+
export interface OAuthDeviceCodeFlowOptions<T> {
|
|
33
|
+
/** Poll the provider once and classify the response. */
|
|
34
|
+
poll(): OAuthDeviceCodePollResult<T> | Promise<OAuthDeviceCodePollResult<T>>;
|
|
35
|
+
/** Provider-requested polling cadence; defaults to RFC 8628's five seconds. */
|
|
36
|
+
intervalSeconds?: number;
|
|
37
|
+
/** Provider-issued expiry window for the device code. */
|
|
38
|
+
expiresInSeconds?: number;
|
|
39
|
+
/** Cancels the flow with the legacy "Login cancelled" error. */
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function abortableDeviceFlowSleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
|
44
|
+
if (!signal) {
|
|
45
|
+
await Bun.sleep(ms);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (signal.aborted) {
|
|
49
|
+
throw new Error(DEVICE_FLOW_CANCEL_MESSAGE);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
53
|
+
let timer: Timer | undefined;
|
|
54
|
+
const onAbort = () => {
|
|
55
|
+
if (timer) clearTimeout(timer);
|
|
56
|
+
reject(new Error(DEVICE_FLOW_CANCEL_MESSAGE));
|
|
57
|
+
};
|
|
58
|
+
timer = setTimeout(() => {
|
|
59
|
+
signal.removeEventListener("abort", onAbort);
|
|
60
|
+
resolve();
|
|
61
|
+
}, ms);
|
|
62
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
63
|
+
await promise;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Poll an OAuth device-code flow until completion, provider failure, timeout, or cancellation. */
|
|
67
|
+
export async function pollOAuthDeviceCodeFlow<T>(options: OAuthDeviceCodeFlowOptions<T>): Promise<T> {
|
|
68
|
+
const deadline =
|
|
69
|
+
typeof options.expiresInSeconds === "number"
|
|
70
|
+
? Date.now() + options.expiresInSeconds * 1000
|
|
71
|
+
: Number.POSITIVE_INFINITY;
|
|
72
|
+
let intervalMs = Math.max(
|
|
73
|
+
MINIMUM_DEVICE_FLOW_INTERVAL_MS,
|
|
74
|
+
Math.floor((options.intervalSeconds ?? DEFAULT_DEVICE_FLOW_INTERVAL_SECONDS) * 1000),
|
|
75
|
+
);
|
|
76
|
+
let slowDownResponses = 0;
|
|
77
|
+
|
|
78
|
+
while (Date.now() < deadline) {
|
|
79
|
+
if (options.signal?.aborted) {
|
|
80
|
+
throw new Error(DEVICE_FLOW_CANCEL_MESSAGE);
|
|
81
|
+
}
|
|
82
|
+
const result = await options.poll();
|
|
83
|
+
if (result.status === "complete") {
|
|
84
|
+
return result.value;
|
|
85
|
+
}
|
|
86
|
+
if (result.status === "failed") {
|
|
87
|
+
throw new Error(result.message);
|
|
88
|
+
}
|
|
89
|
+
if (result.status === "slow_down") {
|
|
90
|
+
slowDownResponses += 1;
|
|
91
|
+
intervalMs = Math.max(MINIMUM_DEVICE_FLOW_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const remainingMs = deadline - Date.now();
|
|
95
|
+
if (remainingMs <= 0) {
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
await abortableDeviceFlowSleep(Math.min(intervalMs, remainingMs), options.signal);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
throw new Error(slowDownResponses > 0 ? DEVICE_FLOW_SLOW_DOWN_TIMEOUT_MESSAGE : DEVICE_FLOW_TIMEOUT_MESSAGE);
|
|
102
|
+
}
|
|
103
|
+
|
|
16
104
|
const builtInOAuthProviders: OAuthProviderInfo[] = PROVIDER_REGISTRY.filter(
|
|
17
105
|
provider => provider.login && provider.showInLoginList !== false,
|
|
18
106
|
).map(provider => ({
|