@oh-my-pi/pi-ai 16.1.21 → 16.1.23

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,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.23] - 2026-06-26
6
+
7
+ ### Added
8
+
9
+ - Added a third streaming thinking-loop detection heuristic to catch "progress-lexicon stalls" where models endlessly reshuffle motivational filler without introducing new vocabulary or concrete technical references
10
+ - Added branded wordmark and logo animation to authentication flow
11
+ - Added a third streaming thinking-loop detection shape — a *progress-lexicon stall* — alongside verbatim tail repetition and near-duplicate (trigram) segments. It catches reasoning-summarizer loops that reshuffle the same motivational filler ("just doing it, pushing ahead, maintaining momentum") into fresh word order every paragraph: word-trigrams never cluster, but a run of substantial segments that recycle the recent vocabulary and introduce no *new* concrete reference (path / identifier / code-span) trips the guard. Summarizer title/heading lines (`**Bold Title**`, `## Heading`) are stripped before analysis so their ever-changing wording cannot mask the stall by inflating novelty. Calibrated against 537k real non-Gemini reasoning blocks (zero false positives at novelty floor 0.2 / run length 8; the real loop sustains runs of 10+).
12
+ - Added CoreWeave Serverless Inference provider login support via `COREWEAVE_API_KEY` and `WANDB_API_KEY` fallback.
13
+
14
+ ### Changed
15
+
16
+ - Redesigned the OAuth callback page (`oauth.html`) to match the oh-my-pi web brand language: OKLCH purple-tinted dark neutrals, magenta→iris→cyan brand gradient on the wordmark, frosted-glass card over an ascii grid backdrop, and a colored status halo around the success/error icon. All assets are inlined; the `__OAUTH_STATE__` injection contract and success/error JS logic are unchanged.
17
+
18
+ ### Fixed
19
+
20
+ - Fixed local llama.cpp (and any local OpenAI-compatible server rendering the Qwen3.6+ chat template) re-processing the full prompt every new user message even with `replayReasoningContent` enabled (#3541 follow-up to #3528). Sending `reasoning_content` alone wasn't enough: Qwen3's chat template strips `<think>...</think>` from any assistant turn whose index is `<= last_query_index`, so the moment a new user message (the user's next prompt, or the auto-learn capture-at-stop nudge) lands, every prior assistant turn becomes "older" and is re-rendered without the `<think>` block — diverging from the generation tokens still in the slot's KV cache. The chat-completions encoder now emits `preserve_thinking: true` for Qwen thinking dialects on local servers, route-split the same way the existing `enable_thinking` emission is: the `qwen` dialect rides the top-level field (llama.cpp's `--jinja` hook and Alibaba Cloud Model Studio's compatible-mode), the `qwen-chat-template` dialect (NVIDIA NIM, vLLM/SGLang's chat-template-kwargs path) rides only `chat_template_kwargs.preserve_thinking` because NIM's request schema is `additionalProperties: false` and rejects unknown top-level fields (#2299). The emission is hoisted above the `reasoning.enabled` gate so it fires for THREE cases the original gating missed: (1) runtime-discovered local Qwen models that ship with `reasoning: false` because the upstream `/v1/models` doesn't advertise the capability (same gotcha #3532 fixed for `replayReasoningContent`), (2) caller-disabled reasoning (`/think off`) — the kwarg is a history-rendering knob, not a per-turn thinking switch, and the slot still holds `<think>` tokens from earlier turns, and (3) forced-tool-choice / DeepSeek-style auto-disable. Qwen3.6+ then renders `<think>...</think>` for every assistant turn regardless of position, and the next-turn render matches the cached generation tokens. ([#3541](https://github.com/can1357/oh-my-pi/issues/3541))
21
+
22
+ ## [16.1.22] - 2026-06-26
23
+
24
+ ### Fixed
25
+
26
+ - 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))
27
+
5
28
  ## [16.1.21] - 2026-06-26
6
29
 
7
30
  ### Fixed
@@ -194,8 +194,10 @@ export type OpenAICompletionsParams = Omit<ChatCompletionCreateParamsStreaming,
194
194
  keep?: "all";
195
195
  };
196
196
  enable_thinking?: boolean;
197
+ preserve_thinking?: boolean;
197
198
  chat_template_kwargs?: {
198
- enable_thinking: boolean;
199
+ enable_thinking?: boolean;
200
+ preserve_thinking?: boolean;
199
201
  };
200
202
  reasoning?: {
201
203
  effort?: string;
@@ -22,6 +22,7 @@ type ModelsEndpointValidation = {
22
22
  kind: "models-endpoint";
23
23
  provider: string;
24
24
  modelsUrl: string;
25
+ headers?: Record<string, string> | (() => Record<string, string> | undefined);
25
26
  };
26
27
  export type ApiKeyLoginConfig = {
27
28
  /** Display name used in error messages, e.g. "Cerebras", "NanoGPT". */
@@ -19,6 +19,7 @@ type ModelListValidationOptions = {
19
19
  provider: string;
20
20
  apiKey: string;
21
21
  modelsUrl: string;
22
+ headers?: Record<string, string> | (() => Record<string, string> | undefined);
22
23
  signal?: AbortSignal;
23
24
  fetch?: FetchImpl;
24
25
  };
@@ -0,0 +1,7 @@
1
+ import type { OAuthLoginCallbacks } from "./oauth/types";
2
+ export declare const loginCoreWeave: (options: import("./oauth").OAuthController) => Promise<string>;
3
+ export declare const coreWeaveProvider: {
4
+ readonly id: "coreweave";
5
+ readonly name: "CoreWeave Serverless Inference";
6
+ readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
7
+ };
@@ -37,6 +37,10 @@ declare const ALL: ({
37
37
  readonly id: "cloudflare-ai-gateway";
38
38
  readonly name: "Cloudflare AI Gateway";
39
39
  readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
40
+ } | {
41
+ readonly id: "coreweave";
42
+ readonly name: "CoreWeave Serverless Inference";
43
+ readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
40
44
  } | {
41
45
  readonly id: "cursor";
42
46
  readonly name: "Cursor (Claude, GPT, etc.)";
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.21",
4
+ "version": "16.1.23",
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.21",
42
- "@oh-my-pi/pi-utils": "16.1.21",
43
- "@oh-my-pi/pi-wire": "16.1.21",
41
+ "@oh-my-pi/pi-catalog": "16.1.23",
42
+ "@oh-my-pi/pi-utils": "16.1.23",
43
+ "@oh-my-pi/pi-wire": "16.1.23",
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
 
@@ -13,6 +13,11 @@ import type {
13
13
  ResolvedOpenAISharedCompat,
14
14
  VercelGatewayRouting,
15
15
  } from "@oh-my-pi/pi-catalog/types";
16
+ import {
17
+ COREWEAVE_PROJECT_HEADER,
18
+ coreWeaveProjectHeaders,
19
+ hasCoreWeaveProjectHeader,
20
+ } from "@oh-my-pi/pi-catalog/wire/coreweave";
16
21
  import { parseGitHubCopilotApiKey } from "@oh-my-pi/pi-catalog/wire/github-copilot";
17
22
  import { $env, extractHttpStatusFromError, logger, structuredCloneJSON } from "@oh-my-pi/pi-utils";
18
23
  import {
@@ -141,6 +146,16 @@ function resolveSakanaRequestBaseUrl(): string | undefined {
141
146
  return normalizeSakanaRequestBaseUrl($env.SAKANA_BASE_URL) ?? normalizeSakanaRequestBaseUrl($env.FUGU_BASE_URL);
142
147
  }
143
148
 
149
+ function applyCoreWeaveProjectHeader(headers: Record<string, string>): void {
150
+ if (hasCoreWeaveProjectHeader(headers)) {
151
+ return;
152
+ }
153
+ const projectHeaders = coreWeaveProjectHeaders($env);
154
+ if (projectHeaders) {
155
+ headers[COREWEAVE_PROJECT_HEADER] = projectHeaders[COREWEAVE_PROJECT_HEADER];
156
+ }
157
+ }
158
+
144
159
  export function resolveOpenAIRequestSetup(
145
160
  model: OpenAIRequestSetupModel,
146
161
  options: OpenAIRequestSetupOptions,
@@ -160,6 +175,9 @@ export function resolveOpenAIRequestSetup(
160
175
  Object.assign(headers, getOpenRouterHeaders());
161
176
  }
162
177
  Object.assign(headers, options.extraHeaders);
178
+ if (model.provider === "coreweave") {
179
+ applyCoreWeaveProjectHeader(headers);
180
+ }
163
181
  if (options.prependHeaders) {
164
182
  headers = { ...options.prependHeaders(), ...headers };
165
183
  }
@@ -585,7 +603,8 @@ export type OpenAICompletionsParams = Omit<ChatCompletionCreateParamsStreaming,
585
603
  repetition_penalty?: number;
586
604
  thinking?: { type: "enabled" | "disabled"; keep?: "all" };
587
605
  enable_thinking?: boolean;
588
- chat_template_kwargs?: { enable_thinking: boolean };
606
+ preserve_thinking?: boolean;
607
+ chat_template_kwargs?: { enable_thinking?: boolean; preserve_thinking?: boolean };
589
608
  reasoning?: { effort?: string } | { enabled: false };
590
609
  reasoning_effort?: string | null;
591
610
  service_tier?: ResolvedServiceTier;
@@ -813,6 +832,50 @@ function encodeChatCompletionsDisabledReasoning(
813
832
  }
814
833
 
815
834
  export function applyChatCompletionsCompatPolicy(params: OpenAICompletionsParams, policy: OpenAICompatPolicy): void {
835
+ // `preserve_thinking` is a chat-template HISTORY knob, not a per-turn
836
+ // thinking switch — it controls whether OLDER assistant turns render
837
+ // with `<think>...</think>` on Qwen3.6+. Emit it BEFORE the reasoning
838
+ // state branches and EVERY early-return below, because the wire shape
839
+ // must carry the kwarg in three cases the auto-detected
840
+ // `qwenPreserveThinking` flag covers but `reasoning.enabled` does not:
841
+ //
842
+ // 1. Discovered local Qwen models. `discoverOpenAICompatibleModels`
843
+ // stamps `reasoning: false` on every spec built from a generic
844
+ // `/v1/models` endpoint (the upstream doesn't advertise the
845
+ // capability), so `model.reasoning === false` → `reasoning.enabled
846
+ // === false`, the body wouldn't otherwise see the kwarg, and the
847
+ // encoder's `replayReasoningContent` branch would keep shipping
848
+ // `reasoning_content` only for the template to strip `<think>` from
849
+ // older turns anyway. Exactly the #3528 / #3541 symptom on every
850
+ // discovered Qwen build.
851
+ // 2. Caller-disabled reasoning. The slot's KV cache still holds prior
852
+ // `<think>...</think>` tokens from earlier thinking turns; the
853
+ // template must keep rendering them or cache invalidates at the
854
+ // first historic `<think>`.
855
+ // 3. Forced-tool-choice / DeepSeek-style auto-disable. Same reasoning
856
+ // as (2) — historic thinking blocks have to survive history replay
857
+ // even when the current turn cannot think.
858
+ //
859
+ // Non-Qwen templates ignore the parameter (jinja `is defined` check
860
+ // silently no-ops), so emitting it unconditionally for the Qwen-family
861
+ // + local-cache compat flag is safe.
862
+ if (policy.compat.qwenPreserveThinking) {
863
+ // Mirror the dialect split that gates `enable_thinking`. The
864
+ // `qwen` dialect rides the top-level field (the only place
865
+ // llama.cpp's `--jinja` hook AND Alibaba Cloud Model Studio's
866
+ // compatible-mode look) while the `qwen-chat-template` dialect
867
+ // (NVIDIA NIM, vLLM/SGLang's chat-template-kwargs path) MUST
868
+ // ride only the kwargs copy — NIM's request schema is
869
+ // `additionalProperties: false` and rejects every unknown
870
+ // top-level field, the very reason `enable_thinking` is
871
+ // route-split this way (#2299, see `catalog/src/compat/openai.ts`
872
+ // thinkingFormat comment).
873
+ if (policy.compat.thinkingFormat === "qwen") {
874
+ params.preserve_thinking = true;
875
+ }
876
+ params.chat_template_kwargs = { ...params.chat_template_kwargs, preserve_thinking: true };
877
+ }
878
+
816
879
  const reasoning = policy.reasoning;
817
880
  if ((!reasoning.modelSupported && !reasoning.disabled) || !reasoning.supportsParams) return;
818
881
  if (reasoning.enabled) {
@@ -832,7 +895,10 @@ export function applyChatCompletionsCompatPolicy(params: OpenAICompletionsParams
832
895
  params.enable_thinking = true;
833
896
  break;
834
897
  case "qwen-template-false":
835
- params.chat_template_kwargs = { enable_thinking: true };
898
+ // Spread so the `preserve_thinking` kwarg hoisted above
899
+ // survives the merge — a bare `{ enable_thinking: true }`
900
+ // would clobber it.
901
+ params.chat_template_kwargs = { ...params.chat_template_kwargs, enable_thinking: true };
836
902
  break;
837
903
  case "openrouter-enabled-false":
838
904
  if (reasoning.wireEffort !== undefined) {
@@ -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" || !model.reasoning) return false;
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,
@@ -31,6 +31,7 @@ type ModelsEndpointValidation = {
31
31
  kind: "models-endpoint";
32
32
  provider: string;
33
33
  modelsUrl: string;
34
+ headers?: Record<string, string> | (() => Record<string, string> | undefined);
34
35
  };
35
36
 
36
37
  export type ApiKeyLoginConfig = {
@@ -98,6 +99,7 @@ export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthCon
98
99
  provider: config.validation.provider,
99
100
  apiKey: trimmed,
100
101
  modelsUrl: config.validation.modelsUrl,
102
+ headers: config.validation.headers,
101
103
  signal: options.signal,
102
104
  fetch: options.fetch,
103
105
  });
@@ -21,6 +21,7 @@ type ModelListValidationOptions = {
21
21
  provider: string;
22
22
  apiKey: string;
23
23
  modelsUrl: string;
24
+ headers?: Record<string, string> | (() => Record<string, string> | undefined);
24
25
  signal?: AbortSignal;
25
26
  fetch?: FetchImpl;
26
27
  };
@@ -32,6 +33,12 @@ function normalizeAnthropicCompatibleBaseUrl(baseUrl: string): string {
32
33
  return trimmed.endsWith("/v1") ? trimmed.slice(0, -3) : trimmed;
33
34
  }
34
35
 
36
+ function resolveValidationHeaders(
37
+ headers: Record<string, string> | (() => Record<string, string> | undefined) | undefined,
38
+ ): Record<string, string> | undefined {
39
+ return typeof headers === "function" ? headers() : headers;
40
+ }
41
+
35
42
  /**
36
43
  * Validate an API key against an OpenAI-compatible chat completions endpoint.
37
44
  *
@@ -129,6 +136,7 @@ export async function validateApiKeyAgainstModelsEndpoint(options: ModelListVali
129
136
  const response = await fetchImpl(options.modelsUrl, {
130
137
  method: "GET",
131
138
  headers: {
139
+ ...(resolveValidationHeaders(options.headers) ?? {}),
132
140
  Authorization: `Bearer ${options.apiKey}`,
133
141
  },
134
142
  signal,
@@ -0,0 +1,38 @@
1
+ import { coreWeaveProjectHeaders } from "@oh-my-pi/pi-catalog/wire/coreweave";
2
+ import { $env } from "@oh-my-pi/pi-utils";
3
+ import { createApiKeyLogin } from "./api-key-login";
4
+ import type { OAuthLoginCallbacks } from "./oauth/types";
5
+ import type { ProviderDefinition } from "./types";
6
+
7
+ const PROJECT_SETUP_INSTRUCTIONS =
8
+ "Create or select a CoreWeave Serverless Inference project, set COREWEAVE_PROJECT=<team>/<project> for the OpenAI-Project header, then copy your API key from account settings";
9
+
10
+ function requireCoreWeaveProjectHeaders(): Record<string, string> {
11
+ const headers = coreWeaveProjectHeaders($env);
12
+ if (!headers) {
13
+ throw new Error(
14
+ "CoreWeave Serverless Inference requires OpenAI-Project. Set COREWEAVE_PROJECT=<team>/<project> before running /login coreweave.",
15
+ );
16
+ }
17
+ return headers;
18
+ }
19
+
20
+ export const loginCoreWeave = createApiKeyLogin({
21
+ providerLabel: "CoreWeave Serverless Inference",
22
+ authUrl: "https://wandb.ai/settings",
23
+ instructions: PROJECT_SETUP_INSTRUCTIONS,
24
+ promptMessage: "Paste your CoreWeave Serverless Inference API key",
25
+ placeholder: "api-key",
26
+ validation: {
27
+ kind: "models-endpoint",
28
+ provider: "CoreWeave Serverless Inference",
29
+ modelsUrl: "https://api.inference.wandb.ai/v1/models",
30
+ headers: requireCoreWeaveProjectHeaders,
31
+ },
32
+ });
33
+
34
+ export const coreWeaveProvider = {
35
+ id: "coreweave",
36
+ name: "CoreWeave Serverless Inference",
37
+ login: (cb: OAuthLoginCallbacks) => loginCoreWeave(cb),
38
+ } as const satisfies ProviderDefinition;
@@ -3,71 +3,150 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Authentication</title>
6
+ <title>oh my pi · authentication</title>
7
7
  <style>
8
8
  :root {
9
- --bg: #09090b;
10
- --card-bg: #18181b;
11
- --text-main: #fafafa;
12
- --text-muted: #a1a1aa;
13
- --success: #22c55e;
14
- --error: #ef4444;
15
- --border: #27272a;
9
+ /* dark, slightly purple-tinted neutrals — never #000 */
10
+ --ink-0: oklch(0.135 0.018 295);
11
+ --ink-1: oklch(0.172 0.022 295);
12
+ --ink-2: oklch(0.21 0.024 295);
13
+ --ink-4: oklch(0.36 0.024 295);
14
+ --ink-5: oklch(0.5 0.022 290);
15
+ --ink-6: oklch(0.66 0.018 290);
16
+ --ink-7: oklch(0.82 0.014 285);
17
+ --ink-9: oklch(0.97 0.008 90);
18
+
19
+ /* brand: magenta lead, cyan reply */
20
+ --magenta: oklch(0.7 0.24 340);
21
+ --iris: oklch(0.62 0.21 295);
22
+ --cyan: oklch(0.81 0.14 200);
23
+
24
+ /* signal */
25
+ --success: oklch(0.78 0.16 150);
26
+ --error: oklch(0.66 0.22 25);
27
+
28
+ --bg: var(--ink-0);
29
+ --fg: var(--ink-9);
30
+ --fg-dim: var(--ink-7);
31
+ --fg-faint: var(--ink-5);
32
+ --rule: oklch(1 0 0 / 0.07);
33
+ --rule-strong: oklch(1 0 0 / 0.14);
34
+ --accent: var(--magenta);
35
+ --accent-2: var(--cyan);
36
+
37
+ color-scheme: dark;
38
+ }
39
+
40
+ * {
41
+ box-sizing: border-box;
16
42
  }
43
+
17
44
  body {
18
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
19
- background-color: var(--bg);
20
- color: var(--text-main);
45
+ font-family: "Geist", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
46
+ background:
47
+ radial-gradient(ellipse 80vw 60vh at 18% -10%, oklch(0.7 0.24 340 / 0.13), transparent 60%),
48
+ radial-gradient(ellipse 70vw 70vh at 110% 30%, oklch(0.81 0.14 200 / 0.09), transparent 60%),
49
+ var(--bg);
50
+ color: var(--fg);
21
51
  display: flex;
22
52
  align-items: center;
23
53
  justify-content: center;
24
- height: 100vh;
54
+ min-height: 100vh;
25
55
  margin: 0;
26
- overflow: hidden;
56
+ padding: 1.5rem;
57
+ -webkit-font-smoothing: antialiased;
58
+ text-rendering: optimizeLegibility;
59
+ overflow-x: clip;
27
60
  }
61
+
62
+ /* ascii grid backdrop */
63
+ .grid-bg {
64
+ position: fixed;
65
+ inset: 0;
66
+ background-image:
67
+ linear-gradient(to right, oklch(1 0 0 / 0.025) 1px, transparent 1px),
68
+ linear-gradient(to bottom, oklch(1 0 0 / 0.025) 1px, transparent 1px);
69
+ background-size: 56px 56px;
70
+ background-position: -1px -1px;
71
+ mask-image: radial-gradient(ellipse at 50% 30%, #000 30%, transparent 75%);
72
+ pointer-events: none;
73
+ z-index: 0;
74
+ }
75
+
28
76
  .container {
29
- background: var(--card-bg);
30
- border: 1px solid var(--border);
31
- border-radius: 12px;
32
- padding: 2.5rem;
77
+ position: relative;
78
+ z-index: 1;
79
+ background: oklch(0.172 0.022 295 / 0.72);
80
+ backdrop-filter: blur(16px);
81
+ -webkit-backdrop-filter: blur(16px);
82
+ border: 1px solid var(--rule);
83
+ border-radius: 16px;
84
+ padding: 2.5rem 2.5rem 2rem;
33
85
  width: 100%;
34
- max-width: 400px;
86
+ max-width: 420px;
35
87
  text-align: center;
36
88
  box-shadow:
37
- 0 20px 25px -5px rgba(0, 0, 0, 0.1),
38
- 0 10px 10px -5px rgba(0, 0, 0, 0.04);
89
+ 0 24px 48px -12px oklch(0 0 0 / 0.55),
90
+ 0 0 0 1px oklch(1 0 0 / 0.03) inset;
39
91
  opacity: 0;
40
92
  transform: translateY(10px);
41
- animation: fadeIn 0.4s ease-out forwards;
93
+ animation: rise 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
42
94
  }
43
- @keyframes fadeIn {
95
+ @keyframes rise {
44
96
  to {
45
97
  opacity: 1;
46
- transform: translateY(0);
98
+ transform: none;
47
99
  }
48
100
  }
49
- .icon-circle {
50
- position: relative;
51
- width: 64px;
52
- height: 64px;
53
- border-radius: 50%;
101
+
102
+ /* brand header */
103
+ .brand {
54
104
  display: flex;
55
105
  align-items: center;
56
106
  justify-content: center;
107
+ gap: 0.5rem;
108
+ margin-bottom: 2rem;
109
+ }
110
+ .brand svg {
111
+ width: 22px;
112
+ height: 22px;
113
+ }
114
+ .brand .wordmark {
115
+ font-size: 13px;
116
+ font-weight: 500;
117
+ letter-spacing: -0.01em;
118
+ color: var(--fg);
119
+ }
120
+
121
+ /* status mark */
122
+ .mark-wrap {
123
+ position: relative;
124
+ width: 72px;
125
+ height: 72px;
57
126
  margin: 0 auto 1.5rem;
58
- background: rgba(255, 255, 255, 0.05);
127
+ display: flex;
128
+ align-items: center;
129
+ justify-content: center;
59
130
  }
60
- .icon {
61
- width: 32px;
62
- height: 32px;
131
+ .mark-wrap::before {
132
+ content: "";
133
+ position: absolute;
134
+ inset: 0;
135
+ border-radius: 50%;
136
+ background: oklch(1 0 0 / 0.05);
137
+ z-index: 0;
138
+ }
139
+ .mark {
140
+ width: 40px;
141
+ height: 40px;
63
142
  z-index: 2;
64
143
  }
65
144
  .timer-svg {
66
145
  position: absolute;
67
146
  top: -2px;
68
147
  left: -2px;
69
- width: 68px;
70
- height: 68px;
148
+ width: 76px;
149
+ height: 76px;
71
150
  transform: rotate(-90deg);
72
151
  z-index: 1;
73
152
  pointer-events: none;
@@ -76,7 +155,7 @@
76
155
  fill: none;
77
156
  stroke-width: 2;
78
157
  stroke-linecap: round;
79
- stroke-dasharray: 201;
158
+ stroke-dasharray: 226;
80
159
  stroke-dashoffset: 0;
81
160
  }
82
161
  .countdown .timer-circle {
@@ -84,43 +163,55 @@
84
163
  }
85
164
  @keyframes countdown {
86
165
  to {
87
- stroke-dashoffset: 201;
166
+ stroke-dashoffset: 226;
88
167
  }
89
168
  }
169
+
90
170
  h1 {
91
- font-size: 1.5rem;
92
- font-weight: 600;
171
+ font-family: "Fraunces", "Iowan Old Style", Georgia, serif;
172
+ font-size: 1.6rem;
173
+ font-weight: 500;
174
+ letter-spacing: -0.022em;
175
+ line-height: 1.15;
93
176
  margin: 0 0 0.75rem;
94
- letter-spacing: -0.025em;
177
+ text-wrap: balance;
95
178
  }
96
179
  p {
97
- color: var(--text-muted);
98
- line-height: 1.5;
99
- margin: 0 0 1.5rem;
180
+ color: var(--fg-dim);
181
+ line-height: 1.6;
182
+ margin: 0 0 1.75rem;
100
183
  font-size: 0.95rem;
101
184
  }
185
+
102
186
  .btn {
103
- display: inline-block;
104
- background: var(--text-main);
187
+ display: inline-flex;
188
+ align-items: center;
189
+ gap: 0.5rem;
190
+ background: var(--fg);
105
191
  color: var(--bg);
192
+ font-family: inherit;
106
193
  font-weight: 500;
107
- padding: 0.6rem 1.2rem;
108
- border-radius: 6px;
194
+ padding: 0.65rem 1.4rem;
195
+ border-radius: 8px;
109
196
  text-decoration: none;
110
197
  font-size: 0.9rem;
111
- transition: opacity 0.2s;
198
+ transition: opacity 0.2s, transform 0.2s;
112
199
  cursor: pointer;
113
200
  border: none;
114
201
  }
115
202
  .btn:hover {
116
- opacity: 0.9;
203
+ opacity: 0.88;
204
+ transform: translateY(-1px);
205
+ }
206
+ .btn:active {
207
+ transform: translateY(0);
117
208
  }
118
209
 
119
210
  /* State: success */
120
- .success .icon-circle {
121
- background: rgba(34, 197, 94, 0.1);
211
+ .success .mark-wrap::before {
212
+ background: oklch(0.78 0.16 150 / 0.12);
122
213
  }
123
- .success .icon {
214
+ .success .mark {
124
215
  color: var(--success);
125
216
  }
126
217
  .success .timer-circle {
@@ -131,10 +222,10 @@
131
222
  }
132
223
 
133
224
  /* State: error */
134
- .error .icon-circle {
135
- background: rgba(239, 68, 68, 0.1);
225
+ .error .mark-wrap::before {
226
+ background: oklch(0.66 0.22 25 / 0.12);
136
227
  }
137
- .error .icon {
228
+ .error .mark {
138
229
  color: var(--error);
139
230
  }
140
231
  .error .timer-circle {
@@ -144,21 +235,48 @@
144
235
  .error .timer-svg {
145
236
  display: none;
146
237
  }
238
+
239
+ @media (prefers-reduced-motion: reduce) {
240
+ .container,
241
+ .countdown .timer-circle {
242
+ animation: none;
243
+ }
244
+ .container {
245
+ opacity: 1;
246
+ transform: none;
247
+ }
248
+ }
147
249
  </style>
148
250
  </head>
149
251
  <body>
252
+ <span aria-hidden="true" class="grid-bg"></span>
150
253
  <div id="app" class="container">
151
- <div class="icon-circle">
152
- <svg class="timer-svg" viewBox="0 0 68 68">
153
- <circle class="timer-circle" cx="34" cy="34" r="32"></circle>
254
+ <div class="brand">
255
+ <svg viewBox="0 0 64 64" aria-hidden="true">
256
+ <defs>
257
+ <linearGradient id="pi-grad" x1="0" y1="0" x2="1" y2="1">
258
+ <stop offset="0" stop-color="oklch(0.7 0.24 340)" />
259
+ <stop offset=".5" stop-color="oklch(0.62 0.21 295)" />
260
+ <stop offset="1" stop-color="oklch(0.81 0.14 200)" />
261
+ </linearGradient>
262
+ </defs>
263
+ <path fill="url(#pi-grad)" d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" />
154
264
  </svg>
155
- <svg class="icon icon-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
265
+ <span class="wordmark">oh my pi</span>
266
+ </div>
267
+
268
+ <div class="mark-wrap">
269
+ <svg class="timer-svg" viewBox="0 0 76 76">
270
+ <circle class="timer-circle" cx="38" cy="38" r="36"></circle>
271
+ </svg>
272
+ <svg class="mark icon-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
156
273
  <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
157
274
  </svg>
158
- <svg class="icon icon-error" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
275
+ <svg class="mark icon-error" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
159
276
  <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
160
277
  </svg>
161
278
  </div>
279
+
162
280
  <h1 id="title">Authentication</h1>
163
281
  <p id="message"></p>
164
282
  <button onclick="window.close()" class="btn">Close Window</button>
@@ -6,6 +6,7 @@ import { anthropicProvider } from "./anthropic";
6
6
  import { azureProvider } from "./azure";
7
7
  import { cerebrasProvider } from "./cerebras";
8
8
  import { cloudflareAiGatewayProvider } from "./cloudflare-ai-gateway";
9
+ import { coreWeaveProvider } from "./coreweave";
9
10
  import { cursorProvider } from "./cursor";
10
11
  import { deepseekProvider } from "./deepseek";
11
12
  import { devinProvider } from "./devin";
@@ -112,6 +113,7 @@ const ALL = [
112
113
  syntheticProvider,
113
114
  nanogptProvider,
114
115
  waferServerlessProvider,
116
+ coreWeaveProvider,
115
117
  vercelAiGatewayProvider,
116
118
  cloudflareAiGatewayProvider,
117
119
  litellmProvider,
@@ -16,7 +16,7 @@
16
16
  * replay-unsafe and is never retried), so the turn is discarded and re-sampled
17
17
  * instead of committing the garbage transcript.
18
18
  *
19
- * Two failure shapes are detected:
19
+ * Three failure shapes are detected:
20
20
  * 1. **Verbatim tail repetition** — a short unit repeated back-to-back (e.g.
21
21
  * "🌊 🌊 🌊 …"). Caught from a rolling 250-char tail.
22
22
  * 2. **Near-duplicate segments** — paragraphs that normalize to the same
@@ -24,6 +24,12 @@
24
24
  * paragraphs. Thresholds were calibrated on a real loop transcript plus
25
25
  * 13.5k non-loop thinking blocks (zero false positives; hardest negative
26
26
  * scored 3 against the trigger of 4).
27
+ * 3. **Progress-lexicon stall** — paragraphs that keep reshuffling the same
28
+ * motivational filler ("just doing it, pushing ahead, maintaining momentum")
29
+ * into fresh word order, so trigrams never match, yet introduce no new
30
+ * vocabulary and name nothing concrete. Caught by a run of low-novelty,
31
+ * anchor-free segments; a segment naming a path/identifier resets the run, so
32
+ * genuine but vocabulary-repetitive work (per-file templates) is spared.
27
33
  *
28
34
  * Scope is narrow: guarded Gemini/DeepSeek streams before any tool call. Native
29
35
  * thinking is checked first; assistant text can also be checked for providers
@@ -63,6 +69,32 @@ const SEGMENT_MIN_COUNT = 8;
63
69
  /** Near-duplicate cluster size (current + matches) that trips the loop. */
64
70
  const SEGMENT_MIN_CLUSTER = 4;
65
71
 
72
+ /** Recent segments whose pooled unigram vocabulary is the novelty baseline for
73
+ * progress-lexicon stall detection. */
74
+ const LEX_NOVELTY_WINDOW = 8;
75
+ /** Novelty (fraction of a segment's content words unseen across the recent
76
+ * window) at/below which a segment counts as recycling earlier wording.
77
+ * Calibrated against 536k real non-Gemini reasoning blocks: at 0.2 the longest
78
+ * low-information run any legitimate block reached was 7. */
79
+ const LEX_STALL_NOVELTY_FLOOR = 0.2;
80
+ /** Consecutive low-information segments that trip a progress-lexicon stall. Set
81
+ * to 8 (one above the worst legitimate run observed in the 536k-block corpus) so
82
+ * the heuristic stays clear of focused reasoning that briefly recycles wording;
83
+ * the real reasoning-summarizer loop sustains far longer runs (10+). */
84
+ const LEX_STALL_MIN_RUN = 8;
85
+
86
+ /** A concrete reference the model is actually reasoning about: a code span, a
87
+ * file extension / dotted member, a multi-segment path, or a snake/camel/Pascal
88
+ * identifier. A segment that introduces a NEW one resets the lexical-stall run —
89
+ * this spares genuine per-target work (per-file templates, focused single-symbol
90
+ * debugging) while still catching reworded filler that names nothing new ("just
91
+ * doing it, pushing ahead") or fixates on one unchanging reference. Excludes bare
92
+ * digits, abbreviations, and decimals (e.g. "Step 2", "i.e.", "1.2") so numbered
93
+ * or punctuated filler is not self-anchoring. Global flag: collected with
94
+ * matchAll, so never used with the stateful test(). */
95
+ const CONCRETE_ANCHOR =
96
+ /`[^`]+`|\b\w{2,}\.[a-zA-Z]\w{0,4}\b|[\w-]+(?:\/[\w-]+){2,}|\b\w+_\w+\b|\b[a-z]+[A-Z]\w*\b|\b[A-Z][a-z]+[A-Z]\w*\b/g;
97
+
66
98
  const OPENAI_COMPAT_GUARDED_APIS: Partial<Record<Api, true>> = {
67
99
  "openai-completions": true,
68
100
  "openai-responses": true,
@@ -113,6 +145,15 @@ export class ThinkingLoopDetector {
113
145
  #window: Set<string>[] = [];
114
146
  /** Count of substantial segments seen so far (warm-up gate). */
115
147
  #count = 0;
148
+ /** Unigram word sets of the most recent segments (≤ LEX_NOVELTY_WINDOW); the
149
+ * novelty baseline for progress-lexicon stall detection. */
150
+ #wordWindow: Set<string>[] = [];
151
+ /** Consecutive low-information (low-novelty, anchor-free) segments seen. */
152
+ #lexStallRun = 0;
153
+ /** Concrete anchors seen per recent segment (≤ LEX_NOVELTY_WINDOW). A stall is
154
+ * only broken by a *new* reference, so filler repeating one fixed
155
+ * path/identifier every paragraph is still caught. */
156
+ #anchorWindow: Set<string>[] = [];
116
157
 
117
158
  push(delta: string): string | null {
118
159
  if (!delta) return null;
@@ -167,22 +208,71 @@ export class ThinkingLoopDetector {
167
208
  return null;
168
209
  }
169
210
 
170
- #consumeSegment(segment: string): string | null {
211
+ #consumeSegment(raw: string): string | null {
212
+ // Reasoning-summarizer titles ("**Maintaining Momentum**", "## Heading")
213
+ // are per-thought formatting, not chain-of-thought; their ever-changing
214
+ // wording would otherwise mask a loop by inflating novelty. Strip them
215
+ // before analysis (a title-only segment then falls below the length gate).
216
+ const segment = raw.replace(/^[ \t]*#{1,6}[ \t].*$/gm, "").replace(/^[ \t]*\*{2,3}.+?\*{2,3}[ \t]*$/gm, "");
171
217
  const normalized = normalizeSegment(segment);
172
218
  if (normalized.length < SEGMENT_MIN_NORM_CHARS) return null;
173
219
 
220
+ // (a) Near-duplicate trigram cluster: the same paragraph reused with
221
+ // cosmetic wording drift (high word-trigram overlap).
174
222
  const fingerprint = trigramShingles(normalized);
175
223
  let cluster = 1;
176
224
  for (const prev of this.#window) {
177
225
  if (jaccard(fingerprint, prev) >= SEGMENT_SIMILARITY) cluster++;
178
226
  }
179
227
 
228
+ // (b) Progress-lexicon stall: paragraphs that recycle the recent
229
+ // vocabulary (low novelty) and add no *new* concrete reference — reworded
230
+ // filler that burns budget without advancing. The trigram check above
231
+ // already claims high-overlap near-duplicates; this catches the
232
+ // low-overlap, reshuffled-wording shape it misses. Requiring a NEW anchor
233
+ // (not merely any anchor) still catches filler that name-drops one fixed
234
+ // path/identifier every paragraph, while sparing genuine per-target work
235
+ // that names a fresh file/symbol each time.
236
+ const words = new Set<string>(normalized.split(" ").filter(Boolean));
237
+ const priorVocab = new Set<string>();
238
+ for (const set of this.#wordWindow) for (const w of set) priorVocab.add(w);
239
+ let unseen = 0;
240
+ for (const w of words) if (!priorVocab.has(w)) unseen++;
241
+ const novelty = priorVocab.size === 0 ? 1 : unseen / words.size;
242
+
243
+ const anchors = new Set<string>();
244
+ // Canonicalize so the same reference written as `Foo`, Foo, or FOO is one
245
+ // anchor and cannot masquerade as "new" to keep a fixed-reference stall alive.
246
+ for (const match of segment.matchAll(CONCRETE_ANCHOR)) anchors.add(match[0].replace(/`/g, "").toLowerCase());
247
+ let newAnchor = false;
248
+ for (const anchor of anchors) {
249
+ if (this.#anchorWindow.every(seen => !seen.has(anchor))) {
250
+ newAnchor = true;
251
+ break;
252
+ }
253
+ }
254
+
255
+ if (novelty <= LEX_STALL_NOVELTY_FLOOR && !newAnchor) {
256
+ this.#lexStallRun++;
257
+ } else {
258
+ this.#lexStallRun = 0;
259
+ }
260
+
180
261
  this.#window.push(fingerprint);
181
262
  if (this.#window.length > SEGMENT_WINDOW) this.#window.shift();
263
+ this.#wordWindow.push(words);
264
+ if (this.#wordWindow.length > LEX_NOVELTY_WINDOW) this.#wordWindow.shift();
265
+ this.#anchorWindow.push(anchors);
266
+ if (this.#anchorWindow.length > LEX_NOVELTY_WINDOW) this.#anchorWindow.shift();
182
267
  this.#count++;
183
268
 
184
- if (this.#count >= SEGMENT_MIN_COUNT && cluster >= SEGMENT_MIN_CLUSTER) {
185
- return `${cluster} near-identical segments within the last ${SEGMENT_WINDOW}`;
269
+ if (this.#count >= SEGMENT_MIN_COUNT) {
270
+ if (cluster >= SEGMENT_MIN_CLUSTER) {
271
+ return `${cluster} near-identical segments within the last ${SEGMENT_WINDOW}`;
272
+ }
273
+ if (this.#lexStallRun >= LEX_STALL_MIN_RUN) {
274
+ return `${this.#lexStallRun} low-information segments recycling recent wording`;
275
+ }
186
276
  }
187
277
  return null;
188
278
  }