@oh-my-pi/pi-ai 16.1.2 → 16.1.4
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 +28 -1
- package/dist/types/providers/anthropic.d.ts +7 -0
- package/dist/types/providers/openai-completions.d.ts +6 -0
- package/dist/types/providers/openai-responses.d.ts +4 -1
- package/dist/types/utils/empty-completion-retry.d.ts +21 -0
- package/package.json +4 -4
- package/src/auth-storage.ts +69 -27
- package/src/providers/anthropic.ts +12 -1
- package/src/providers/google-gemini-cli.ts +1 -1
- package/src/providers/ollama.ts +17 -21
- package/src/providers/openai-completions.ts +12 -12
- package/src/providers/openai-responses.ts +11 -1
- package/src/utils/empty-completion-retry.ts +159 -0
- package/src/utils/thinking-loop.ts +18 -19
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.1.4] - 2026-06-19
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added bounded auto-retry for empty assistant completions specifically to the OpenAI Responses provider
|
|
10
|
+
- Added bounded auto-retry for empty assistant completions across the OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages providers. A benign terminal stop that streamed no content and billed no output tokens — the signature of a flaky OpenAI-/Anthropic-compatible gateway that intermittently 200s with an empty body — is now retried up to twice with exponential backoff (honoring `providerRetryWait`) before being surfaced, instead of silently stalling the agent loop. Retries fire only before any content streams, so live streaming (including thinking) is never delayed, retried, or duplicated.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Fixed the Antigravity (`google-antigravity`) request builder dropping `labels.model_enum` when the wire profile does not declare one. Required for Claude 4.6 ids whose `AntigravityModelWireProfile` carries only `maxOutputTokens` (no captured `model_enum`); the label is now emitted only when the catalog defines it. ([#3067](https://github.com/can1357/oh-my-pi/issues/3067))
|
|
15
|
+
|
|
16
|
+
## [16.1.3] - 2026-06-19
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Added regression test pinning that `openai-completions` emits a `thinking` block for `reasoning_content` deltas even when `delta.content` is explicitly JSON `null` (the DeepSeek-format dual-key pattern used by custom GLM/Qwen reasoning providers). See [#2996](https://github.com/can1357/oh-my-pi/issues/2996).
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- Improved the thinking loop guard to treat assistant text loops as retryable errors
|
|
25
|
+
- Refined text normalization logic to reduce false positives in the thinking loop detector
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Fixed Ollama chat requests sending image payloads to text-only models. Image blocks are now omitted and replaced with the standard non-vision placeholder for models without vision support, while vision-capable Ollama models continue to receive images. ([#3009](https://github.com/can1357/oh-my-pi/pull/3009) by [@serverinspector](https://github.com/serverinspector))
|
|
30
|
+
- Fixed `SqliteAuthCredentialStore.close()` leaking one-off prepared statements created by inline `this.#db.prepare()` calls in `#authCredentialsTableExists`, `#readAuthSchemaVersion`, `#inferAuthSchemaVersion`, `#migrateAuthSchemaV0ToV1`, `#backfillCredentialIdentityKeys`, and `updateAuthCredential`. Each statement is now wrapped in `try/finally` with `stmt.finalize()`, and the `close()` method finalizes `#insertUsageCostStmt` and `#listUsageCostsStmt` which were previously missed. This caused EBUSY on Windows when tests tried to delete temp dirs containing open SQLite handles.
|
|
31
|
+
|
|
5
32
|
## [16.1.2] - 2026-06-19
|
|
6
33
|
|
|
7
34
|
### Added
|
|
@@ -3934,4 +3961,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
|
|
|
3934
3961
|
|
|
3935
3962
|
## [0.9.4] - 2025-11-26
|
|
3936
3963
|
|
|
3937
|
-
Initial release with multi-provider LLM support.
|
|
3964
|
+
Initial release with multi-provider LLM support.
|
|
@@ -190,6 +190,13 @@ export type AnthropicUsageLike = {
|
|
|
190
190
|
* zero-valued objects clear prior extras from earlier stream usage snapshots.
|
|
191
191
|
*/
|
|
192
192
|
export declare function applyAnthropicUsageExtras(usage: Usage, source: AnthropicUsageLike): void;
|
|
193
|
+
/**
|
|
194
|
+
* Public entry: wrap the single-attempt streamer with bounded empty-completion
|
|
195
|
+
* retries (a benign terminal stop carrying no content/usage would otherwise
|
|
196
|
+
* stall the agent loop). The inner attempt keeps its own provider-failure retry
|
|
197
|
+
* loop; this layer only re-issues a fresh request on an empty success. Shared
|
|
198
|
+
* with the OpenAI-completions provider via `withEmptyCompletionRetry`.
|
|
199
|
+
*/
|
|
193
200
|
export declare const streamAnthropic: StreamFunction<"anthropic-messages">;
|
|
194
201
|
export type AnthropicSystemBlock = {
|
|
195
202
|
type: "text";
|
|
@@ -34,6 +34,12 @@ export interface OpenAICompletionsOptions extends StreamOptions {
|
|
|
34
34
|
*/
|
|
35
35
|
openrouterVariant?: string;
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Public entry: wrap the single-attempt streamer with bounded empty-completion
|
|
39
|
+
* retries — flaky gateways occasionally 200 with `delta: {}` + `finish_reason:
|
|
40
|
+
* "stop"` and no usage, which would otherwise stall the agent loop. Shared with
|
|
41
|
+
* the Anthropic provider via `withEmptyCompletionRetry`.
|
|
42
|
+
*/
|
|
37
43
|
export declare const streamOpenAICompletions: StreamFunction<"openai-completions">;
|
|
38
44
|
export declare function parseChunkUsage(rawUsage: object, model: Model<"openai-completions">, premiumRequests: number | undefined): AssistantMessage["usage"];
|
|
39
45
|
export declare function convertMessages(model: Model<"openai-completions">, context: Context, compat: ResolvedOpenAICompat): ChatCompletionMessageParam[];
|
|
@@ -94,7 +94,10 @@ type OpenAIResponsesSamplingParams = ResponseCreateParamsStreaming & {
|
|
|
94
94
|
};
|
|
95
95
|
};
|
|
96
96
|
/**
|
|
97
|
-
*
|
|
97
|
+
* Public entry: wrap the single-attempt Responses streamer with bounded
|
|
98
|
+
* empty-completion retries — a `response.completed` carrying no content/usage
|
|
99
|
+
* would otherwise stall the agent loop. Shared with the OpenAI-completions and
|
|
100
|
+
* Anthropic providers via `withEmptyCompletionRetry`.
|
|
98
101
|
*/
|
|
99
102
|
export declare const streamOpenAIResponses: StreamFunction<"openai-responses">;
|
|
100
103
|
export declare function buildParams(model: Model<"openai-responses">, context: Context, options: OpenAIResponsesOptions | undefined, providerSessionState: OpenAIResponsesProviderSessionState | undefined, strictToolsScope?: OpenAIStrictToolsScope, disableStrictToolsOverride?: boolean): {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AssistantMessage, Context } from "../types";
|
|
2
|
+
import { AssistantMessageEventStream } from "./event-stream";
|
|
3
|
+
export declare const MAX_EMPTY_COMPLETION_RETRIES = 2;
|
|
4
|
+
export declare const EMPTY_COMPLETION_BASE_DELAY_MS = 500;
|
|
5
|
+
/**
|
|
6
|
+
* Whether a completed assistant message carries content worth delivering: a tool
|
|
7
|
+
* call or any non-whitespace text. An empty/whitespace-only message — or one
|
|
8
|
+
* that only ever produced thinking — is the "empty response" failure.
|
|
9
|
+
*/
|
|
10
|
+
export declare function hasVisibleAssistantContent(message: AssistantMessage): boolean;
|
|
11
|
+
interface EmptyCompletionRetryOptions {
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
providerRetryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Wrap a single-attempt provider stream with bounded empty-completion retries.
|
|
17
|
+
* `attempt` MUST create a fresh request (and its own output message) on each
|
|
18
|
+
* call so a retry never inherits stale metadata from an empty attempt.
|
|
19
|
+
*/
|
|
20
|
+
export declare function withEmptyCompletionRetry<M, O extends EmptyCompletionRetryOptions>(model: M, context: Context, options: O | undefined, attempt: (model: M, context: Context, options?: O) => AssistantMessageEventStream): AssistantMessageEventStream;
|
|
21
|
+
export {};
|
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.4",
|
|
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.4",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.1.4",
|
|
43
|
+
"@oh-my-pi/pi-wire": "16.1.4",
|
|
44
44
|
"arktype": "^2.2.0",
|
|
45
45
|
"partial-json": "^0.1.7",
|
|
46
46
|
"zod": "^4"
|
package/src/auth-storage.ts
CHANGED
|
@@ -4742,25 +4742,47 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
4742
4742
|
}
|
|
4743
4743
|
|
|
4744
4744
|
#authCredentialsTableExists(): boolean {
|
|
4745
|
-
const
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
|
|
4745
|
+
const stmt = this.#db.prepare(
|
|
4746
|
+
"SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'auth_credentials'",
|
|
4747
|
+
);
|
|
4748
|
+
try {
|
|
4749
|
+
const row = stmt.get() as { present?: number } | undefined;
|
|
4750
|
+
return row?.present === 1;
|
|
4751
|
+
} finally {
|
|
4752
|
+
stmt.finalize();
|
|
4753
|
+
}
|
|
4749
4754
|
}
|
|
4750
4755
|
|
|
4751
4756
|
#readAuthSchemaVersion(): number | null {
|
|
4752
|
-
const
|
|
4753
|
-
|
|
4754
|
-
| undefined;
|
|
4755
|
-
|
|
4757
|
+
const stmt = this.#db.prepare("SELECT version FROM auth_schema_version WHERE id = 1");
|
|
4758
|
+
try {
|
|
4759
|
+
const row = stmt.get() as { version?: number } | undefined;
|
|
4760
|
+
return typeof row?.version === "number" ? row.version : null;
|
|
4761
|
+
} finally {
|
|
4762
|
+
stmt.finalize();
|
|
4763
|
+
}
|
|
4756
4764
|
}
|
|
4757
4765
|
|
|
4758
4766
|
#writeAuthSchemaVersion(version: number): void {
|
|
4759
|
-
this.#db.prepare("INSERT OR REPLACE INTO auth_schema_version(id, version) VALUES (1, ?)")
|
|
4767
|
+
const stmt = this.#db.prepare("INSERT OR REPLACE INTO auth_schema_version(id, version) VALUES (1, ?)");
|
|
4768
|
+
try {
|
|
4769
|
+
stmt.run(version);
|
|
4770
|
+
} finally {
|
|
4771
|
+
stmt.finalize();
|
|
4772
|
+
}
|
|
4760
4773
|
}
|
|
4761
4774
|
|
|
4762
4775
|
#inferAuthSchemaVersion(): number {
|
|
4763
|
-
const
|
|
4776
|
+
const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)");
|
|
4777
|
+
try {
|
|
4778
|
+
const cols = stmt.all() as Array<{ name?: string }>;
|
|
4779
|
+
return this.#inferAuthSchemaVersionFromColumns(cols);
|
|
4780
|
+
} finally {
|
|
4781
|
+
stmt.finalize();
|
|
4782
|
+
}
|
|
4783
|
+
}
|
|
4784
|
+
|
|
4785
|
+
#inferAuthSchemaVersionFromColumns(cols: Array<{ name?: string }>): number {
|
|
4764
4786
|
const hasDisabledCause = cols.some(column => column.name === "disabled_cause");
|
|
4765
4787
|
const hasIdentityKey = cols.some(column => column.name === "identity_key");
|
|
4766
4788
|
const hasAccountId = cols.some(column => column.name === "account_id");
|
|
@@ -4808,8 +4830,14 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
4808
4830
|
|
|
4809
4831
|
#migrateAuthSchemaV0ToV1(): void {
|
|
4810
4832
|
const migrate = this.#db.transaction(() => {
|
|
4811
|
-
const
|
|
4812
|
-
|
|
4833
|
+
const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)");
|
|
4834
|
+
let hasDisabled = false;
|
|
4835
|
+
try {
|
|
4836
|
+
const v0Cols = stmt.all() as Array<{ name?: string }>;
|
|
4837
|
+
hasDisabled = v0Cols.some(col => col.name === "disabled");
|
|
4838
|
+
} finally {
|
|
4839
|
+
stmt.finalize();
|
|
4840
|
+
}
|
|
4813
4841
|
|
|
4814
4842
|
this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v0");
|
|
4815
4843
|
this.#db.run(`
|
|
@@ -4885,21 +4913,29 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
4885
4913
|
}
|
|
4886
4914
|
|
|
4887
4915
|
#backfillCredentialIdentityKeys(): void {
|
|
4888
|
-
const
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4916
|
+
const selectRowsStmt = this.#db.prepare(
|
|
4917
|
+
"SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
|
|
4918
|
+
);
|
|
4919
|
+
let rows: AuthRow[];
|
|
4920
|
+
try {
|
|
4921
|
+
rows = selectRowsStmt.all() as AuthRow[];
|
|
4922
|
+
} finally {
|
|
4923
|
+
selectRowsStmt.finalize();
|
|
4924
|
+
}
|
|
4893
4925
|
if (rows.length === 0) return;
|
|
4894
4926
|
|
|
4895
4927
|
let updateIdentity: Statement | null = null;
|
|
4896
|
-
|
|
4897
|
-
const
|
|
4898
|
-
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4928
|
+
try {
|
|
4929
|
+
for (const row of rows) {
|
|
4930
|
+
const identityKey = resolveRowCredentialIdentityKey(row.provider, row);
|
|
4931
|
+
// Rows whose identity cannot be derived stay NULL; writing NULL over
|
|
4932
|
+
// NULL would just burn a write transaction on every boot.
|
|
4933
|
+
if (identityKey === null) continue;
|
|
4934
|
+
updateIdentity ??= this.#db.prepare("UPDATE auth_credentials SET identity_key = ? WHERE id = ?");
|
|
4935
|
+
updateIdentity.run(identityKey, row.id);
|
|
4936
|
+
}
|
|
4937
|
+
} finally {
|
|
4938
|
+
updateIdentity?.finalize();
|
|
4903
4939
|
}
|
|
4904
4940
|
}
|
|
4905
4941
|
|
|
@@ -5063,9 +5099,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5063
5099
|
|
|
5064
5100
|
updateAuthCredential(id: number, credential: AuthCredential): void {
|
|
5065
5101
|
try {
|
|
5066
|
-
const
|
|
5067
|
-
|
|
5068
|
-
|
|
5102
|
+
const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?");
|
|
5103
|
+
let providerRow: { provider?: string } | undefined;
|
|
5104
|
+
try {
|
|
5105
|
+
providerRow = providerStmt.get(id) as { provider?: string } | undefined;
|
|
5106
|
+
} finally {
|
|
5107
|
+
providerStmt.finalize();
|
|
5108
|
+
}
|
|
5069
5109
|
const provider = providerRow?.provider ?? "";
|
|
5070
5110
|
const serialized = serializeCredential(provider, credential);
|
|
5071
5111
|
if (!serialized) return;
|
|
@@ -5334,6 +5374,8 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5334
5374
|
this.#lastUsageHistoryStmt.finalize();
|
|
5335
5375
|
this.#listUsageHistoryStmt.finalize();
|
|
5336
5376
|
this.#updateUsageHistoryStmt.finalize();
|
|
5377
|
+
this.#insertUsageCostStmt.finalize();
|
|
5378
|
+
this.#listUsageCostsStmt.finalize();
|
|
5337
5379
|
this.#db.close();
|
|
5338
5380
|
}
|
|
5339
5381
|
}
|
|
@@ -46,6 +46,7 @@ import type {
|
|
|
46
46
|
import { resolveServiceTier } from "../types";
|
|
47
47
|
import { isRecord, normalizeSystemPrompts, normalizeToolCallId, resolveCacheRetention } from "../utils";
|
|
48
48
|
import { createAbortSourceTracker } from "../utils/abort";
|
|
49
|
+
import { withEmptyCompletionRetry } from "../utils/empty-completion-retry";
|
|
49
50
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
50
51
|
import { isFoundryEnabled } from "../utils/foundry";
|
|
51
52
|
import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
|
|
@@ -1575,7 +1576,7 @@ export function applyAnthropicUsageExtras(usage: Usage, source: AnthropicUsageLi
|
|
|
1575
1576
|
}
|
|
1576
1577
|
}
|
|
1577
1578
|
|
|
1578
|
-
|
|
1579
|
+
const streamAnthropicOnce = (
|
|
1579
1580
|
model: Model<"anthropic-messages">,
|
|
1580
1581
|
context: Context,
|
|
1581
1582
|
options?: AnthropicOptions,
|
|
@@ -2309,6 +2310,16 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
|
|
2309
2310
|
return stream;
|
|
2310
2311
|
};
|
|
2311
2312
|
|
|
2313
|
+
/**
|
|
2314
|
+
* Public entry: wrap the single-attempt streamer with bounded empty-completion
|
|
2315
|
+
* retries (a benign terminal stop carrying no content/usage would otherwise
|
|
2316
|
+
* stall the agent loop). The inner attempt keeps its own provider-failure retry
|
|
2317
|
+
* loop; this layer only re-issues a fresh request on an empty success. Shared
|
|
2318
|
+
* with the OpenAI-completions provider via `withEmptyCompletionRetry`.
|
|
2319
|
+
*/
|
|
2320
|
+
export const streamAnthropic: StreamFunction<"anthropic-messages"> = (model, context, options) =>
|
|
2321
|
+
withEmptyCompletionRetry(model, context, options, streamAnthropicOnce);
|
|
2322
|
+
|
|
2312
2323
|
export type AnthropicSystemBlock = {
|
|
2313
2324
|
type: "text";
|
|
2314
2325
|
text: string;
|
|
@@ -940,7 +940,7 @@ function buildAntigravityRequestEnvelope(
|
|
|
940
940
|
const labels: Record<string, string> = {};
|
|
941
941
|
if (state?.lastExecutionId) labels.last_execution_id = state.lastExecutionId;
|
|
942
942
|
labels.last_step_index = String(step - 1);
|
|
943
|
-
if (profile) labels.model_enum = profile.modelEnum;
|
|
943
|
+
if (profile?.modelEnum !== undefined) labels.model_enum = profile.modelEnum;
|
|
944
944
|
labels.trajectory_id = trajectoryId;
|
|
945
945
|
labels.used_claude = String(isClaude);
|
|
946
946
|
labels.used_claude_conservative = String(isClaude);
|
package/src/providers/ollama.ts
CHANGED
|
@@ -5,15 +5,14 @@ import type {
|
|
|
5
5
|
Api,
|
|
6
6
|
AssistantMessage,
|
|
7
7
|
Context,
|
|
8
|
-
|
|
8
|
+
ImageContent,
|
|
9
9
|
Message,
|
|
10
10
|
Model,
|
|
11
11
|
StreamFunction,
|
|
12
12
|
StreamOptions,
|
|
13
|
+
TextContent,
|
|
13
14
|
Tool,
|
|
14
15
|
ToolChoice,
|
|
15
|
-
ToolResultMessage,
|
|
16
|
-
UserMessage,
|
|
17
16
|
} from "../types";
|
|
18
17
|
import { normalizeSystemPrompts } from "../utils";
|
|
19
18
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
@@ -32,6 +31,7 @@ import {
|
|
|
32
31
|
type StreamMarkupHealingEvent,
|
|
33
32
|
} from "../utils/stream-markup-healing";
|
|
34
33
|
import { transformMessages } from "./transform-messages";
|
|
34
|
+
import { joinTextWithImagePlaceholder, partitionVisionContent } from "./vision-guard";
|
|
35
35
|
|
|
36
36
|
/** Non-2xx response from the Ollama `/api/chat` endpoint. */
|
|
37
37
|
export class OllamaApiError extends ProviderHttpError {
|
|
@@ -174,40 +174,35 @@ function selectToolsForToolChoice(tools: Tool[] | undefined, toolChoice: ToolCho
|
|
|
174
174
|
return [];
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
function toPlainContent(
|
|
177
|
+
function toPlainContent(
|
|
178
|
+
content: string | ReadonlyArray<TextContent | ImageContent>,
|
|
179
|
+
supportsImages: boolean,
|
|
180
|
+
): {
|
|
178
181
|
content: string;
|
|
179
182
|
images?: string[];
|
|
180
183
|
} {
|
|
181
184
|
if (typeof content === "string") {
|
|
182
185
|
return { content };
|
|
183
186
|
}
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
for (const block of content) {
|
|
187
|
-
if (block.type === "text" && typeof block.text === "string") {
|
|
188
|
-
textParts.push(block.text);
|
|
189
|
-
}
|
|
190
|
-
if (block.type === "image" && typeof block.data === "string") {
|
|
191
|
-
images.push(block.data);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
187
|
+
const { textBlocks, imageBlocks, omittedImages } = partitionVisionContent(content, supportsImages);
|
|
188
|
+
const text = textBlocks.map(block => block.text).join("\n");
|
|
194
189
|
return {
|
|
195
|
-
content:
|
|
196
|
-
...(
|
|
190
|
+
content: joinTextWithImagePlaceholder(text, omittedImages),
|
|
191
|
+
...(imageBlocks.length > 0 ? { images: imageBlocks.map(block => block.data) } : {}),
|
|
197
192
|
};
|
|
198
193
|
}
|
|
199
194
|
|
|
200
|
-
function convertMessage(message: Message): OllamaMessage {
|
|
195
|
+
function convertMessage(message: Message, supportsImages: boolean): OllamaMessage {
|
|
201
196
|
if (message.role === "user") {
|
|
202
|
-
const converted = toPlainContent(message.content
|
|
197
|
+
const converted = toPlainContent(message.content, supportsImages);
|
|
203
198
|
return { role: "user", ...converted };
|
|
204
199
|
}
|
|
205
200
|
if (message.role === "developer") {
|
|
206
|
-
const converted = toPlainContent(message.content
|
|
201
|
+
const converted = toPlainContent(message.content, supportsImages);
|
|
207
202
|
return { role: "system", ...converted };
|
|
208
203
|
}
|
|
209
204
|
if (message.role === "toolResult") {
|
|
210
|
-
const converted = toPlainContent(message.content
|
|
205
|
+
const converted = toPlainContent(message.content, supportsImages);
|
|
211
206
|
return {
|
|
212
207
|
role: "tool",
|
|
213
208
|
tool_name: message.toolName,
|
|
@@ -259,8 +254,9 @@ function convertMessages(model: Model<"ollama-chat">, context: Context): OllamaM
|
|
|
259
254
|
}
|
|
260
255
|
messages.push(...context.messages);
|
|
261
256
|
const isCloud = model.provider === "ollama-cloud";
|
|
257
|
+
const supportsImages = model.input.includes("image");
|
|
262
258
|
return transformMessages(messages, model).map(msg => {
|
|
263
|
-
const converted = convertMessage(msg);
|
|
259
|
+
const converted = convertMessage(msg, supportsImages);
|
|
264
260
|
// Ollama cloud rejects requests when assistant history messages contain the `thinking`
|
|
265
261
|
// field — it's valid in model responses but not accepted as a history input. Strip it
|
|
266
262
|
// to prevent HTTP 400 errors. Local Ollama instances are unaffected.
|
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
} from "../types";
|
|
28
28
|
import { normalizeSystemPrompts } from "../utils";
|
|
29
29
|
import { createAbortSourceTracker } from "../utils/abort";
|
|
30
|
+
import { hasVisibleAssistantContent, withEmptyCompletionRetry } from "../utils/empty-completion-retry";
|
|
30
31
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
31
32
|
import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
|
|
32
33
|
import {
|
|
@@ -537,7 +538,7 @@ const OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE =
|
|
|
537
538
|
// converts the already-successful response into a timeout error.
|
|
538
539
|
const OPENAI_COMPLETIONS_POST_FINISH_GRACE_MS = 2_500;
|
|
539
540
|
|
|
540
|
-
|
|
541
|
+
const streamOpenAICompletionsOnce = (
|
|
541
542
|
model: Model<"openai-completions">,
|
|
542
543
|
context: Context,
|
|
543
544
|
options?: OpenAICompletionsOptions,
|
|
@@ -1234,7 +1235,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
1234
1235
|
if (
|
|
1235
1236
|
policy.stream.emptyLengthFinishIsContextError &&
|
|
1236
1237
|
output.stopReason === "length" &&
|
|
1237
|
-
!
|
|
1238
|
+
!hasVisibleAssistantContent(output)
|
|
1238
1239
|
) {
|
|
1239
1240
|
output.stopReason = "error";
|
|
1240
1241
|
output.errorMessage = EMPTY_OLLAMA_LENGTH_COMPLETION_MESSAGE;
|
|
@@ -1288,6 +1289,15 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
1288
1289
|
return stream;
|
|
1289
1290
|
};
|
|
1290
1291
|
|
|
1292
|
+
/**
|
|
1293
|
+
* Public entry: wrap the single-attempt streamer with bounded empty-completion
|
|
1294
|
+
* retries — flaky gateways occasionally 200 with `delta: {}` + `finish_reason:
|
|
1295
|
+
* "stop"` and no usage, which would otherwise stall the agent loop. Shared with
|
|
1296
|
+
* the Anthropic provider via `withEmptyCompletionRetry`.
|
|
1297
|
+
*/
|
|
1298
|
+
export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (model, context, options) =>
|
|
1299
|
+
withEmptyCompletionRetry(model, context, options, streamOpenAICompletionsOnce);
|
|
1300
|
+
|
|
1291
1301
|
function createRequestSetup(
|
|
1292
1302
|
model: Model<"openai-completions">,
|
|
1293
1303
|
context: Context,
|
|
@@ -2061,16 +2071,6 @@ function convertTools(
|
|
|
2061
2071
|
};
|
|
2062
2072
|
}
|
|
2063
2073
|
|
|
2064
|
-
const NON_WHITESPACE_RE = /\S/;
|
|
2065
|
-
|
|
2066
|
-
function hasVisibleCompletionContent(message: AssistantMessage): boolean {
|
|
2067
|
-
for (const block of message.content) {
|
|
2068
|
-
if (block.type === "toolCall") return true;
|
|
2069
|
-
if (block.type === "text" && NON_WHITESPACE_RE.test(block.text)) return true;
|
|
2070
|
-
}
|
|
2071
|
-
return false;
|
|
2072
|
-
}
|
|
2073
|
-
|
|
2074
2074
|
const EMPTY_OLLAMA_LENGTH_COMPLETION_MESSAGE =
|
|
2075
2075
|
"Model returned no content: prompt filled the context window; raise Ollama num_ctx or shorten the prompt.";
|
|
2076
2076
|
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
sanitizeOpenAIResponsesHistoryItemsForReplay,
|
|
22
22
|
} from "../utils";
|
|
23
23
|
import { createAbortSourceTracker } from "../utils/abort";
|
|
24
|
+
import { withEmptyCompletionRetry } from "../utils/empty-completion-retry";
|
|
24
25
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
25
26
|
import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
|
|
26
27
|
import {
|
|
@@ -338,7 +339,7 @@ type OpenAIResponsesSamplingParams = ResponseCreateParamsStreaming & {
|
|
|
338
339
|
/**
|
|
339
340
|
* Generate function for OpenAI Responses API
|
|
340
341
|
*/
|
|
341
|
-
|
|
342
|
+
const streamOpenAIResponsesOnce = (
|
|
342
343
|
model: Model<"openai-responses">,
|
|
343
344
|
context: Context,
|
|
344
345
|
options?: OpenAIResponsesOptions,
|
|
@@ -737,6 +738,15 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
|
|
737
738
|
return stream;
|
|
738
739
|
};
|
|
739
740
|
|
|
741
|
+
/**
|
|
742
|
+
* Public entry: wrap the single-attempt Responses streamer with bounded
|
|
743
|
+
* empty-completion retries — a `response.completed` carrying no content/usage
|
|
744
|
+
* would otherwise stall the agent loop. Shared with the OpenAI-completions and
|
|
745
|
+
* Anthropic providers via `withEmptyCompletionRetry`.
|
|
746
|
+
*/
|
|
747
|
+
export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (model, context, options) =>
|
|
748
|
+
withEmptyCompletionRetry(model, context, options, streamOpenAIResponsesOnce);
|
|
749
|
+
|
|
740
750
|
export function buildParams(
|
|
741
751
|
model: Model<"openai-responses">,
|
|
742
752
|
context: Context,
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded retries for an empty assistant completion.
|
|
3
|
+
*
|
|
4
|
+
* Some providers — and especially flaky OpenAI-/Anthropic-compatible gateways —
|
|
5
|
+
* intermittently return a benign terminal stop carrying no content and no usage
|
|
6
|
+
* (e.g. a single OpenAI `delta: {}` + `finish_reason: "stop"` chunk). Delivered
|
|
7
|
+
* as-is the agent loop has nothing to act on and silently halts mid-task, so the
|
|
8
|
+
* request must be retried instead of surfaced.
|
|
9
|
+
*
|
|
10
|
+
* This wraps a single-attempt provider stream and re-invokes it (a fresh request
|
|
11
|
+
* with its own message state) when an attempt produces no meaningful content.
|
|
12
|
+
* Only a stream that streamed nothing meaningful is retried: the moment any
|
|
13
|
+
* text/thinking/tool delta is forwarded the attempt is committed, so live
|
|
14
|
+
* streaming (including thinking) is never delayed, retried, or duplicated.
|
|
15
|
+
*
|
|
16
|
+
* Mirrors the Gemini empty-response policy in `google-shared` (which keeps its
|
|
17
|
+
* own integrated loop) and is shared by the OpenAI-completions and
|
|
18
|
+
* Anthropic-messages providers.
|
|
19
|
+
*/
|
|
20
|
+
import { scheduler } from "node:timers/promises";
|
|
21
|
+
import type { AssistantMessage, AssistantMessageEvent, Context } from "../types";
|
|
22
|
+
import { AssistantMessageEventStream } from "./event-stream";
|
|
23
|
+
|
|
24
|
+
export const MAX_EMPTY_COMPLETION_RETRIES = 2;
|
|
25
|
+
export const EMPTY_COMPLETION_BASE_DELAY_MS = 500;
|
|
26
|
+
|
|
27
|
+
const NON_WHITESPACE_RE = /\S/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether a completed assistant message carries content worth delivering: a tool
|
|
31
|
+
* call or any non-whitespace text. An empty/whitespace-only message — or one
|
|
32
|
+
* that only ever produced thinking — is the "empty response" failure.
|
|
33
|
+
*/
|
|
34
|
+
export function hasVisibleAssistantContent(message: AssistantMessage): boolean {
|
|
35
|
+
for (const block of message.content) {
|
|
36
|
+
if (block.type === "toolCall") return true;
|
|
37
|
+
if (block.type === "text" && NON_WHITESPACE_RE.test(block.text)) return true;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A streamed event that delivers content worth committing the attempt for. */
|
|
43
|
+
function isMeaningfulCompletionEvent(event: AssistantMessageEvent): boolean {
|
|
44
|
+
switch (event.type) {
|
|
45
|
+
case "text_delta":
|
|
46
|
+
case "thinking_delta":
|
|
47
|
+
case "toolcall_delta":
|
|
48
|
+
return event.delta.length > 0;
|
|
49
|
+
case "text_end":
|
|
50
|
+
case "thinking_end":
|
|
51
|
+
return event.content.length > 0;
|
|
52
|
+
case "toolcall_start":
|
|
53
|
+
case "toolcall_end":
|
|
54
|
+
return true;
|
|
55
|
+
default:
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface EmptyCompletionRetryOptions {
|
|
61
|
+
signal?: AbortSignal;
|
|
62
|
+
providerRetryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Wrap a single-attempt provider stream with bounded empty-completion retries.
|
|
67
|
+
* `attempt` MUST create a fresh request (and its own output message) on each
|
|
68
|
+
* call so a retry never inherits stale metadata from an empty attempt.
|
|
69
|
+
*/
|
|
70
|
+
export function withEmptyCompletionRetry<M, O extends EmptyCompletionRetryOptions>(
|
|
71
|
+
model: M,
|
|
72
|
+
context: Context,
|
|
73
|
+
options: O | undefined,
|
|
74
|
+
attempt: (model: M, context: Context, options?: O) => AssistantMessageEventStream,
|
|
75
|
+
): AssistantMessageEventStream {
|
|
76
|
+
const outer = new AssistantMessageEventStream();
|
|
77
|
+
const signal = options?.signal;
|
|
78
|
+
void (async () => {
|
|
79
|
+
for (let emptyAttempt = 0; ; emptyAttempt++) {
|
|
80
|
+
const inner = attempt(model, context, options);
|
|
81
|
+
const buffered: AssistantMessageEvent[] = [];
|
|
82
|
+
let committed = false;
|
|
83
|
+
let terminal: AssistantMessageEvent | undefined;
|
|
84
|
+
const flush = (): void => {
|
|
85
|
+
for (const event of buffered) outer.push(event);
|
|
86
|
+
buffered.length = 0;
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
for await (const event of inner) {
|
|
90
|
+
if (event.type === "done" || event.type === "error") {
|
|
91
|
+
terminal = event;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
// Buffer pre-content events (start/*_start) so an empty attempt can
|
|
95
|
+
// be discarded; commit the moment real content streams.
|
|
96
|
+
if (!committed && !isMeaningfulCompletionEvent(event)) {
|
|
97
|
+
buffered.push(event);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
committed = true;
|
|
101
|
+
flush();
|
|
102
|
+
outer.push(event);
|
|
103
|
+
if (outer.done) return;
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
flush();
|
|
107
|
+
outer.fail(error);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Retry only a genuinely degenerate completion: a normal stop that
|
|
112
|
+
// produced no visible content AND billed no output tokens (the flaky
|
|
113
|
+
// gateway signature — charged nothing, returned nothing). A stop that
|
|
114
|
+
// reports output tokens spent its budget somewhere (e.g. thinking) and
|
|
115
|
+
// is left alone.
|
|
116
|
+
const message = terminal?.type === "done" ? terminal.message : undefined;
|
|
117
|
+
const isRetryableEmpty =
|
|
118
|
+
!committed &&
|
|
119
|
+
message !== undefined &&
|
|
120
|
+
message.stopReason === "stop" &&
|
|
121
|
+
!message.errorMessage &&
|
|
122
|
+
(message.usage?.output ?? 0) <= 0 &&
|
|
123
|
+
!hasVisibleAssistantContent(message);
|
|
124
|
+
|
|
125
|
+
if (isRetryableEmpty && emptyAttempt < MAX_EMPTY_COMPLETION_RETRIES && !signal?.aborted) {
|
|
126
|
+
const delayMs = EMPTY_COMPLETION_BASE_DELAY_MS * 2 ** emptyAttempt;
|
|
127
|
+
try {
|
|
128
|
+
if (options?.providerRetryWait) await options.providerRetryWait(delayMs, signal);
|
|
129
|
+
else await scheduler.wait(delayMs, { signal });
|
|
130
|
+
} catch (waitError) {
|
|
131
|
+
// Aborted during backoff: deliver the empty result rather than hang.
|
|
132
|
+
// Any other wait failure is a real error and must surface.
|
|
133
|
+
flush();
|
|
134
|
+
if (signal?.aborted) {
|
|
135
|
+
if (terminal) outer.push(terminal);
|
|
136
|
+
} else {
|
|
137
|
+
outer.fail(waitError);
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// Discard the buffered `start` from this empty attempt and retry.
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
flush();
|
|
146
|
+
if (terminal) {
|
|
147
|
+
outer.push(terminal);
|
|
148
|
+
} else if (!outer.done) {
|
|
149
|
+
try {
|
|
150
|
+
outer.end(await inner.result());
|
|
151
|
+
} catch (error) {
|
|
152
|
+
outer.fail(error);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
})();
|
|
158
|
+
return outer;
|
|
159
|
+
}
|
|
@@ -25,11 +25,11 @@
|
|
|
25
25
|
* 13.5k non-loop thinking blocks (zero false positives; hardest negative
|
|
26
26
|
* scored 3 against the trigger of 4).
|
|
27
27
|
*
|
|
28
|
-
* Scope is
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
28
|
+
* Scope is narrow: guarded Gemini/DeepSeek streams before any tool call. Native
|
|
29
|
+
* thinking is checked first; assistant text can also be checked for providers
|
|
30
|
+
* that surface reasoning as visible prose. On a hit, the failed turn is emitted
|
|
31
|
+
* as an empty retryable stream-stall error so the session drops and re-samples
|
|
32
|
+
* it instead of committing the runaway transcript. Disable with
|
|
33
33
|
* `PI_NO_THINKING_LOOP_GUARD=1`.
|
|
34
34
|
*/
|
|
35
35
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
@@ -208,7 +208,6 @@ export function guardThinkingLoopStream(
|
|
|
208
208
|
void (async () => {
|
|
209
209
|
let thinkingArmed = true;
|
|
210
210
|
let textArmed = checkAssistantContent;
|
|
211
|
-
let accumulatedText = "";
|
|
212
211
|
try {
|
|
213
212
|
for await (const event of inner) {
|
|
214
213
|
let detail: string | null = null;
|
|
@@ -221,7 +220,6 @@ export function guardThinkingLoopStream(
|
|
|
221
220
|
thinkingArmed = false;
|
|
222
221
|
if (textArmed && event.type === "text_delta") {
|
|
223
222
|
detail = textDetector.push(event.delta);
|
|
224
|
-
accumulatedText += event.delta;
|
|
225
223
|
}
|
|
226
224
|
} else if (event.type === "toolcall_start" || event.type === "toolcall_delta") {
|
|
227
225
|
thinkingArmed = false;
|
|
@@ -244,7 +242,7 @@ export function guardThinkingLoopStream(
|
|
|
244
242
|
outer.push({
|
|
245
243
|
type: "error",
|
|
246
244
|
reason: "error",
|
|
247
|
-
error: buildThinkingLoopError(model, detail
|
|
245
|
+
error: buildThinkingLoopError(model, detail),
|
|
248
246
|
});
|
|
249
247
|
return;
|
|
250
248
|
}
|
|
@@ -289,13 +287,13 @@ export function withGeminiThinkingLoopGuard<
|
|
|
289
287
|
return guardThinkingLoopStream(dispatch(merged), model, controller, options);
|
|
290
288
|
}
|
|
291
289
|
|
|
292
|
-
function buildThinkingLoopError(model: Model<Api>, detail: string
|
|
293
|
-
const hasText = Boolean(accumulatedText);
|
|
290
|
+
function buildThinkingLoopError(model: Model<Api>, detail: string): AssistantMessage {
|
|
294
291
|
return {
|
|
295
292
|
role: "assistant",
|
|
296
|
-
// Empty content is load-bearing:
|
|
297
|
-
//
|
|
298
|
-
|
|
293
|
+
// Empty content is load-bearing: loop-guard output is replay garbage, even
|
|
294
|
+
// when it arrived as assistant text instead of native thinking. Keeping it
|
|
295
|
+
// would persist the failed attempt before AgentSession retries.
|
|
296
|
+
content: [],
|
|
299
297
|
api: model.api,
|
|
300
298
|
provider: model.provider,
|
|
301
299
|
model: model.id,
|
|
@@ -310,7 +308,7 @@ function buildThinkingLoopError(model: Model<Api>, detail: string, accumulatedTe
|
|
|
310
308
|
stopReason: "error",
|
|
311
309
|
// "stream stall" makes the transport/session retry classifiers treat this
|
|
312
310
|
// as a transient (retryable) failure with no bespoke rule.
|
|
313
|
-
errorMessage: `${THINKING_LOOP_ERROR_MARKER}: the model repeated near-identical content (${detail})
|
|
311
|
+
errorMessage: `${THINKING_LOOP_ERROR_MARKER}: the model repeated near-identical content (${detail}). Treating as a stream stall and retrying.`,
|
|
314
312
|
timestamp: Date.now(),
|
|
315
313
|
};
|
|
316
314
|
}
|
|
@@ -345,14 +343,15 @@ function detectVerbatimRepetition(text: string): [unit: string, count: number] |
|
|
|
345
343
|
return null;
|
|
346
344
|
}
|
|
347
345
|
|
|
348
|
-
/** Lowercase
|
|
346
|
+
/** Lowercase and tokenize prose plus code/path payloads, dropping pure numbers. */
|
|
349
347
|
function normalizeSegment(segment: string): string {
|
|
350
348
|
return segment
|
|
351
349
|
.toLowerCase()
|
|
352
|
-
.replace(/`[^`]
|
|
353
|
-
.replace(
|
|
354
|
-
.
|
|
355
|
-
.
|
|
350
|
+
.replace(/`([^`]*)`/g, " $1 ")
|
|
351
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
352
|
+
.split(/\s+/)
|
|
353
|
+
.filter(token => /[a-z]/.test(token))
|
|
354
|
+
.join(" ")
|
|
356
355
|
.trim();
|
|
357
356
|
}
|
|
358
357
|
|