@oh-my-pi/pi-ai 17.3.4 → 17.3.7
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 +30 -0
- package/dist/types/auth-storage.d.ts +12 -2
- package/dist/types/error/rate-limit.d.ts +5 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/oneshot-retry.d.ts +89 -0
- package/dist/types/providers/anthropic-wire.d.ts +18 -5
- package/dist/types/providers/openai-shared.d.ts +1 -1
- package/dist/types/types.d.ts +5 -4
- package/dist/types/usage/kimi.d.ts +3 -1
- package/dist/types/utils/schema/strict-tool-validation.d.ts +10 -1
- package/dist/types/utils/schema/wire.d.ts +6 -0
- package/dist/types/utils/thinking-loop.d.ts +12 -9
- package/package.json +5 -5
- package/src/auth-storage.ts +36 -5
- package/src/error/flags.ts +6 -2
- package/src/error/rate-limit.ts +31 -2
- package/src/index.ts +1 -0
- package/src/oneshot-retry.ts +230 -0
- package/src/providers/anthropic-messages-server.ts +5 -5
- package/src/providers/anthropic-wire.ts +31 -5
- package/src/providers/anthropic.ts +15 -4
- package/src/providers/cursor.ts +44 -4
- package/src/providers/openai-completions.ts +107 -63
- package/src/providers/openai-responses.ts +13 -10
- package/src/providers/openai-shared.ts +10 -3
- package/src/registry/alibaba-coding-plan.ts +2 -2
- package/src/registry/oauth/kimi.ts +20 -0
- package/src/stream.ts +59 -57
- package/src/types.ts +5 -4
- package/src/usage/kimi.ts +15 -0
- package/src/usage/umans.ts +93 -20
- package/src/utils/leaked-thinking-stream.ts +2 -2
- package/src/utils/schema/strict-tool-validation.ts +39 -5
- package/src/utils/schema/wire.ts +31 -0
- package/src/utils/thinking-loop.ts +96 -63
|
@@ -3,7 +3,7 @@ import { isKimiModelId } from "@oh-my-pi/pi-catalog/identity";
|
|
|
3
3
|
import { resolveWireModelId } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
4
4
|
import { calculateCost } from "@oh-my-pi/pi-catalog/models";
|
|
5
5
|
import type { ResolvedOpenAICompat } from "@oh-my-pi/pi-catalog/types";
|
|
6
|
-
import { $env, parseStreamingJson, parseStreamingJsonThrottled } from "@oh-my-pi/pi-utils";
|
|
6
|
+
import { $env, logger, parseStreamingJson, parseStreamingJsonThrottled } from "@oh-my-pi/pi-utils";
|
|
7
7
|
import { renderDemotedThinking } from "../dialect/demotion";
|
|
8
8
|
import * as AIError from "../error";
|
|
9
9
|
import { getKimiCommonHeaders } from "../registry/oauth/kimi";
|
|
@@ -44,6 +44,8 @@ import { notifyProviderResponse } from "../utils/provider-response";
|
|
|
44
44
|
import { callWithCopilotModelRetry } from "../utils/retry";
|
|
45
45
|
import {
|
|
46
46
|
adaptSchemaForStrict,
|
|
47
|
+
findStrictToolSchemaViolation,
|
|
48
|
+
flattenExclusiveRequiredRootUnion,
|
|
47
49
|
NO_STRICT,
|
|
48
50
|
normalizeSchemaForMoonshot,
|
|
49
51
|
sanitizeSchemaForGrammar,
|
|
@@ -1299,6 +1301,16 @@ const streamOpenAICompletionsOnce = (
|
|
|
1299
1301
|
flushDeepseekStripBuffer(true);
|
|
1300
1302
|
}
|
|
1301
1303
|
|
|
1304
|
+
// Detect premature stream closure before the normal block-finalization
|
|
1305
|
+
// sweep. Throwing after that sweep would make the error handler emit a
|
|
1306
|
+
// second text_end/thinking_end for the same partial block.
|
|
1307
|
+
if (streamFinishedAt === undefined && output.content.length > 0) {
|
|
1308
|
+
throw new AIError.ProviderResponseError(
|
|
1309
|
+
"OpenAI completions stream closed before a finish_reason was received",
|
|
1310
|
+
{ provider: model.provider, kind: "incomplete-stream" },
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1302
1314
|
if (currentBlock?.type === "toolCall") {
|
|
1303
1315
|
finishPendingToolCallBlocks();
|
|
1304
1316
|
} else {
|
|
@@ -1575,24 +1587,26 @@ function buildParams(
|
|
|
1575
1587
|
if (options?.minP !== undefined) {
|
|
1576
1588
|
params.min_p = options.minP;
|
|
1577
1589
|
}
|
|
1578
|
-
if (
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1590
|
+
if (initialCompat.supportsPenaltyAndStopParams) {
|
|
1591
|
+
if (options?.presencePenalty !== undefined) {
|
|
1592
|
+
params.presence_penalty = options.presencePenalty;
|
|
1593
|
+
}
|
|
1594
|
+
if (options?.repetitionPenalty !== undefined) {
|
|
1595
|
+
params.repetition_penalty = options.repetitionPenalty;
|
|
1596
|
+
}
|
|
1597
|
+
if (options?.frequencyPenalty !== undefined) {
|
|
1598
|
+
params.frequency_penalty = options.frequencyPenalty;
|
|
1599
|
+
}
|
|
1586
1600
|
}
|
|
1587
1601
|
}
|
|
1588
|
-
if (options?.stopSequences?.length) {
|
|
1602
|
+
if (options?.stopSequences?.length && initialCompat.supportsPenaltyAndStopParams) {
|
|
1589
1603
|
const seqs = options.stopSequences;
|
|
1590
1604
|
params.stop = seqs.length === 1 ? seqs[0] : seqs.slice(0, 4);
|
|
1591
1605
|
}
|
|
1592
1606
|
applyOpenAIServiceTier(params, options?.serviceTier, model);
|
|
1593
1607
|
|
|
1594
1608
|
if (context.tools?.length) {
|
|
1595
|
-
const builtTools = convertTools(context.tools, initialCompat, toolStrictModeOverride);
|
|
1609
|
+
const builtTools = convertTools(context.tools, initialCompat, toolStrictModeOverride, model.provider);
|
|
1596
1610
|
params.tools = builtTools.tools;
|
|
1597
1611
|
toolStrictMode = builtTools.toolStrictMode;
|
|
1598
1612
|
strictToolsApplied = builtTools.strictToolsApplied;
|
|
@@ -1653,7 +1667,10 @@ function buildParams(
|
|
|
1653
1667
|
params.tool_choice = "auto";
|
|
1654
1668
|
}
|
|
1655
1669
|
|
|
1656
|
-
if (
|
|
1670
|
+
if (
|
|
1671
|
+
(!Array.isArray(params.tools) || params.tools.length === 0) &&
|
|
1672
|
+
(params.tool_choice === "none" || isForcedToolChoice(params.tool_choice))
|
|
1673
|
+
) {
|
|
1657
1674
|
// `tool_choice: "none"` with no tools to gate is redundant and also
|
|
1658
1675
|
// trips LiteLLM → Bedrock: the proxy serializes the directive into a
|
|
1659
1676
|
// `toolConfig` block, and Bedrock requires `toolConfig.tools` to be
|
|
@@ -1662,6 +1679,8 @@ function buildParams(
|
|
|
1662
1679
|
// Side-channel turns hit this: `/btw` and IRC background replies route
|
|
1663
1680
|
// through `AgentSession.runEphemeralTurn`, which sets `context.tools = []`
|
|
1664
1681
|
// and `toolChoice: "none"` (see packages/coding-agent/src/session/agent-session.ts).
|
|
1682
|
+
// The same empty-tools case applies after leftover-union quarantine: a
|
|
1683
|
+
// leftover `"required"` / named force would 400 just like the bad schema.
|
|
1665
1684
|
delete params.tool_choice;
|
|
1666
1685
|
}
|
|
1667
1686
|
|
|
@@ -1808,16 +1827,19 @@ export function convertMessages(
|
|
|
1808
1827
|
? 40
|
|
1809
1828
|
: undefined;
|
|
1810
1829
|
const duplicateToolCallIdSuffixPrefix = compat.requiresMistralToolIds ? "dup" : undefined;
|
|
1811
|
-
const normalizeToolCallId = (id: string): string => {
|
|
1830
|
+
const normalizeToolCallId = (id: string, source?: AssistantMessage): string => {
|
|
1812
1831
|
if (compat.requiresMistralToolIds) return normalizeMistralToolId(id, true);
|
|
1813
1832
|
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1833
|
+
const isSameModelSource =
|
|
1834
|
+
source !== undefined &&
|
|
1835
|
+
source.provider === model.provider &&
|
|
1836
|
+
source.api === model.api &&
|
|
1837
|
+
source.model === model.id;
|
|
1838
|
+
// Cross-model replay converts OpenAI Responses composite IDs from
|
|
1839
|
+
// `{call_id}|{item_id}` to the Chat Completions `call_id`. Same-model
|
|
1840
|
+
// Chat Completions IDs are provider-issued opaque correlation tokens.
|
|
1841
|
+
if (!isSameModelSource && id.includes("|")) {
|
|
1819
1842
|
const [callId] = id.split("|");
|
|
1820
|
-
// Sanitize to allowed chars and truncate to 40 chars (OpenAI limit)
|
|
1821
1843
|
return callId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40);
|
|
1822
1844
|
}
|
|
1823
1845
|
|
|
@@ -1827,7 +1849,7 @@ export function convertMessages(
|
|
|
1827
1849
|
const transformedMessages = transformMessages(
|
|
1828
1850
|
context.messages,
|
|
1829
1851
|
model,
|
|
1830
|
-
id => normalizeToolCallId(id),
|
|
1852
|
+
(id, _target, source) => normalizeToolCallId(id, source),
|
|
1831
1853
|
maxNormalizedToolCallIdLength,
|
|
1832
1854
|
duplicateToolCallIdSuffixPrefix,
|
|
1833
1855
|
compat,
|
|
@@ -1859,8 +1881,8 @@ export function convertMessages(
|
|
|
1859
1881
|
return nextId;
|
|
1860
1882
|
};
|
|
1861
1883
|
|
|
1862
|
-
const ensureToolCallId = (rawId: string, seed: string): string => {
|
|
1863
|
-
const normalized = normalizeToolCallId(rawId);
|
|
1884
|
+
const ensureToolCallId = (rawId: string, seed: string, source?: AssistantMessage): string => {
|
|
1885
|
+
const normalized = normalizeToolCallId(rawId, source);
|
|
1864
1886
|
if (normalized.trim().length > 0) return normalized;
|
|
1865
1887
|
return generateFallbackToolCallId(seed);
|
|
1866
1888
|
};
|
|
@@ -2142,7 +2164,7 @@ export function convertMessages(
|
|
|
2142
2164
|
}
|
|
2143
2165
|
if (toolCalls.length > 0) {
|
|
2144
2166
|
assistantMsg.tool_calls = toolCalls.map((tc, toolCallIndex) => {
|
|
2145
|
-
const toolCallId = ensureToolCallId(tc.id, `${i}:${toolCallIndex}:${tc.name}
|
|
2167
|
+
const toolCallId = ensureToolCallId(tc.id, `${i}:${toolCallIndex}:${tc.name}`, msg);
|
|
2146
2168
|
rememberToolCallId(tc.id, toolCallId);
|
|
2147
2169
|
return {
|
|
2148
2170
|
id: normalizeMistralToolId(toolCallId, compat.requiresMistralToolIds),
|
|
@@ -2285,10 +2307,14 @@ function convertTools(
|
|
|
2285
2307
|
tools: Tool[],
|
|
2286
2308
|
compat: ResolvedOpenAICompat,
|
|
2287
2309
|
toolStrictModeOverride?: ToolStrictModeOverride,
|
|
2310
|
+
provider?: string,
|
|
2288
2311
|
): BuiltOpenAICompletionTools {
|
|
2312
|
+
const rejectXaiRootObjectUnion = provider === "xai" || provider === "xai-oauth";
|
|
2289
2313
|
const adaptedTools = tools.map(tool => {
|
|
2290
2314
|
const strict = !NO_STRICT && compat.supportsStrictMode !== false && tool.strict !== false;
|
|
2291
|
-
const baseParameters =
|
|
2315
|
+
const baseParameters = rejectXaiRootObjectUnion
|
|
2316
|
+
? flattenExclusiveRequiredRootUnion(toolWireSchema(tool))
|
|
2317
|
+
: toolWireSchema(tool);
|
|
2292
2318
|
const adapted = adaptSchemaForStrict(baseParameters, strict);
|
|
2293
2319
|
return {
|
|
2294
2320
|
tool,
|
|
@@ -2308,47 +2334,55 @@ function convertTools(
|
|
|
2308
2334
|
: "none"
|
|
2309
2335
|
: "mixed";
|
|
2310
2336
|
|
|
2337
|
+
const wireTools: ChatCompletionTool[] = [];
|
|
2338
|
+
let anyStrictEmitted = false;
|
|
2339
|
+
for (const { tool, baseParameters, parameters, strict } of adaptedTools) {
|
|
2340
|
+
const includeStrict = toolStrictMode === "all_strict" || (toolStrictMode === "mixed" && strict);
|
|
2341
|
+
// `strict: false` is semantically distinct from omitted `strict` on some
|
|
2342
|
+
// backends: with it absent, optional properties may be over-filled with
|
|
2343
|
+
// placeholder values (#4336). Preserve the author's explicit `false`,
|
|
2344
|
+
// but only in "mixed" mode against a provider that understands the
|
|
2345
|
+
// field — the `all_strict → none` collapse and `supportsStrictMode:
|
|
2346
|
+
// false` paths deliberately keep the wire flag uniformly absent.
|
|
2347
|
+
const includeExplicitFalse =
|
|
2348
|
+
!includeStrict && tool.strict === false && toolStrictMode === "mixed" && compat.supportsStrictMode !== false;
|
|
2349
|
+
const wireParameters = includeStrict ? parameters : baseParameters;
|
|
2350
|
+
// Moonshot/Kimi native hosts validate against the stricter MFJS subset
|
|
2351
|
+
// (const→enum, typed enums, no validators) and 400 otherwise.
|
|
2352
|
+
// Grammar-constrained local backends (llama.cpp, LM Studio, vLLM)
|
|
2353
|
+
// build a GBNF grammar from the schema and 400 with
|
|
2354
|
+
// `Unrecognized schema: true` on the bare boolean subschema
|
|
2355
|
+
// `toolWireSchema` emits for open fields (issue #5914).
|
|
2356
|
+
const emittedParameters =
|
|
2357
|
+
compat.toolSchemaFlavor === "moonshot-mfjs"
|
|
2358
|
+
? (normalizeSchemaForMoonshot(wireParameters) as Record<string, unknown>)
|
|
2359
|
+
: compat.toolSchemaFlavor === "grammar"
|
|
2360
|
+
? sanitizeSchemaForGrammar(wireParameters)
|
|
2361
|
+
: wireParameters;
|
|
2362
|
+
const violation = findStrictToolSchemaViolation(emittedParameters, "#", { rejectXaiRootObjectUnion });
|
|
2363
|
+
if (violation) {
|
|
2364
|
+
logger.warn(
|
|
2365
|
+
`Tool "${tool.name}" omitted from the openai-completions request: its parameter schema is invalid for this provider at ${violation} (an enum/const value cannot match its declared type, or leftover xAI object-root union). Other tools are unaffected.`,
|
|
2366
|
+
);
|
|
2367
|
+
continue;
|
|
2368
|
+
}
|
|
2369
|
+
if (includeStrict) anyStrictEmitted = true;
|
|
2370
|
+
wireTools.push({
|
|
2371
|
+
type: "function",
|
|
2372
|
+
function: {
|
|
2373
|
+
name: tool.name,
|
|
2374
|
+
description: tool.description || "",
|
|
2375
|
+
parameters: emittedParameters,
|
|
2376
|
+
// Only include strict if provider supports it. Some reject unknown fields.
|
|
2377
|
+
...(includeStrict ? { strict: true } : includeExplicitFalse ? { strict: false } : {}),
|
|
2378
|
+
},
|
|
2379
|
+
});
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2311
2382
|
return {
|
|
2312
|
-
tools:
|
|
2313
|
-
const includeStrict = toolStrictMode === "all_strict" || (toolStrictMode === "mixed" && strict);
|
|
2314
|
-
// `strict: false` is semantically distinct from omitted `strict` on some
|
|
2315
|
-
// backends: with it absent, optional properties may be over-filled with
|
|
2316
|
-
// placeholder values (#4336). Preserve the author's explicit `false`,
|
|
2317
|
-
// but only in "mixed" mode against a provider that understands the
|
|
2318
|
-
// field — the `all_strict → none` collapse and `supportsStrictMode:
|
|
2319
|
-
// false` paths deliberately keep the wire flag uniformly absent.
|
|
2320
|
-
const includeExplicitFalse =
|
|
2321
|
-
!includeStrict &&
|
|
2322
|
-
tool.strict === false &&
|
|
2323
|
-
toolStrictMode === "mixed" &&
|
|
2324
|
-
compat.supportsStrictMode !== false;
|
|
2325
|
-
const wireParameters = includeStrict ? parameters : baseParameters;
|
|
2326
|
-
return {
|
|
2327
|
-
type: "function",
|
|
2328
|
-
function: {
|
|
2329
|
-
name: tool.name,
|
|
2330
|
-
description: tool.description || "",
|
|
2331
|
-
// Moonshot/Kimi native hosts validate against the stricter MFJS subset
|
|
2332
|
-
// (const→enum, typed enums, no validators) and 400 otherwise.
|
|
2333
|
-
// Grammar-constrained local backends (llama.cpp, LM Studio, vLLM)
|
|
2334
|
-
// build a GBNF grammar from the schema and 400 with
|
|
2335
|
-
// `Unrecognized schema: true` on the bare boolean subschema
|
|
2336
|
-
// `toolWireSchema` emits for open fields (issue #5914).
|
|
2337
|
-
parameters:
|
|
2338
|
-
compat.toolSchemaFlavor === "moonshot-mfjs"
|
|
2339
|
-
? (normalizeSchemaForMoonshot(wireParameters) as Record<string, unknown>)
|
|
2340
|
-
: compat.toolSchemaFlavor === "grammar"
|
|
2341
|
-
? sanitizeSchemaForGrammar(wireParameters)
|
|
2342
|
-
: wireParameters,
|
|
2343
|
-
// Only include strict if provider supports it. Some reject unknown fields.
|
|
2344
|
-
...(includeStrict ? { strict: true } : includeExplicitFalse ? { strict: false } : {}),
|
|
2345
|
-
},
|
|
2346
|
-
};
|
|
2347
|
-
}),
|
|
2383
|
+
tools: wireTools,
|
|
2348
2384
|
toolStrictMode,
|
|
2349
|
-
strictToolsApplied:
|
|
2350
|
-
tools.length > 0 &&
|
|
2351
|
-
(toolStrictMode === "all_strict" || (toolStrictMode === "mixed" && adaptedTools.some(tool => tool.strict))),
|
|
2385
|
+
strictToolsApplied: wireTools.length > 0 && anyStrictEmitted,
|
|
2352
2386
|
};
|
|
2353
2387
|
}
|
|
2354
2388
|
|
|
@@ -2380,6 +2414,16 @@ function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | str
|
|
|
2380
2414
|
// the message to match the session retry classifier's transient-transport
|
|
2381
2415
|
// pattern (`provider.?returned.?error`) and get the turn auto-retried.
|
|
2382
2416
|
return { stopReason: "error", errorMessage: "Provider returned error finish_reason" };
|
|
2417
|
+
case "insufficient_system_resource":
|
|
2418
|
+
// DeepSeek kills the generation mid-stream when its inference system runs
|
|
2419
|
+
// out of resources (docs: "the request is interrupted due to insufficient
|
|
2420
|
+
// resource of the inference system"). Server-side capacity failure — like
|
|
2421
|
+
// the bare `error` case, word the message to match the transient-transport
|
|
2422
|
+
// retry pattern so the turn is auto-retried instead of pinned as an error.
|
|
2423
|
+
return {
|
|
2424
|
+
stopReason: "error",
|
|
2425
|
+
errorMessage: "Provider returned error finish_reason: insufficient_system_resource",
|
|
2426
|
+
};
|
|
2383
2427
|
default:
|
|
2384
2428
|
return {
|
|
2385
2429
|
stopReason: "error",
|
|
@@ -39,6 +39,7 @@ import { callWithCopilotModelRetry } from "../utils/retry";
|
|
|
39
39
|
import {
|
|
40
40
|
adaptSchemaForStrict,
|
|
41
41
|
findStrictToolSchemaViolation,
|
|
42
|
+
flattenExclusiveRequiredRootUnion,
|
|
42
43
|
NO_STRICT,
|
|
43
44
|
normalizeSchemaForMoonshot,
|
|
44
45
|
sanitizeSchemaForOpenAIResponses,
|
|
@@ -1285,12 +1286,11 @@ export function buildParams(
|
|
|
1285
1286
|
filterReasoningHistory: options?.filterReasoningHistory,
|
|
1286
1287
|
omitReasoningEffort: options?.omitReasoningEffort,
|
|
1287
1288
|
});
|
|
1288
|
-
const reasoningSummary =
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
: options?.reasoningSummary;
|
|
1289
|
+
const reasoningSummary = model.compat.supportsReasoningSummary
|
|
1290
|
+
? options?.reasoningSummary
|
|
1291
|
+
: options?.reasoning === undefined
|
|
1292
|
+
? undefined
|
|
1293
|
+
: null;
|
|
1294
1294
|
applyResponsesCompatPolicy(params, reasoningPolicy, {
|
|
1295
1295
|
reasoningSummary,
|
|
1296
1296
|
forceReasoningOff: options?.forceReasoningOff,
|
|
@@ -1388,6 +1388,7 @@ export function convertTools(
|
|
|
1388
1388
|
),
|
|
1389
1389
|
): OpenAITool[] {
|
|
1390
1390
|
const allowFreeform = supportsFreeformApplyPatch(model);
|
|
1391
|
+
const rejectXaiRootObjectUnion = model.provider === "xai" || model.provider === "xai-oauth";
|
|
1391
1392
|
const out: OpenAITool[] = [];
|
|
1392
1393
|
for (const tool of tools) {
|
|
1393
1394
|
if (tool.native?.type === "computer" && model.supportsComputerUse === true) {
|
|
@@ -1420,16 +1421,18 @@ export function convertTools(
|
|
|
1420
1421
|
// subschemas ("property schema … must be an object"), so the Moonshot
|
|
1421
1422
|
// pass re-coerces them last.
|
|
1422
1423
|
const sanitized = sanitizeSchemaForOpenAIResponses(baseParameters);
|
|
1424
|
+
const providerParameters = rejectXaiRootObjectUnion ? flattenExclusiveRequiredRootUnion(sanitized) : sanitized;
|
|
1423
1425
|
const responseParameters =
|
|
1424
1426
|
model.compat.toolSchemaFlavor === "moonshot-mfjs"
|
|
1425
|
-
? (normalizeSchemaForMoonshot(
|
|
1426
|
-
:
|
|
1427
|
+
? (normalizeSchemaForMoonshot(providerParameters) as Record<string, unknown>)
|
|
1428
|
+
: providerParameters;
|
|
1427
1429
|
const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(responseParameters, strict);
|
|
1428
1430
|
// Quarantine a tool whose emitted schema carries a provider-rejecting
|
|
1429
1431
|
// enum/const-vs-type contradiction: dropping just that tool keeps the rest
|
|
1430
1432
|
// of the request valid instead of letting one bad MCP schema 400 the whole
|
|
1431
|
-
// turn (#2652). Other tools and built-ins are unaffected.
|
|
1432
|
-
|
|
1433
|
+
// turn (#2652). Other tools and built-ins are unaffected. Leftover
|
|
1434
|
+
// object-root unions are an xAI-only 400; OpenAI/Azure/Codex keep them.
|
|
1435
|
+
const violation = findStrictToolSchemaViolation(parameters, "#", { rejectXaiRootObjectUnion });
|
|
1433
1436
|
if (violation) {
|
|
1434
1437
|
onQuarantine(tool.name, violation);
|
|
1435
1438
|
continue;
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
parseStreamingJsonThrottled,
|
|
32
32
|
stringifyJson,
|
|
33
33
|
structuredCloneJSON,
|
|
34
|
+
USER_AGENT,
|
|
34
35
|
} from "@oh-my-pi/pi-utils";
|
|
35
36
|
import * as AIError from "../error";
|
|
36
37
|
import {
|
|
@@ -315,6 +316,10 @@ export function resolveOpenAIRequestSetup(
|
|
|
315
316
|
if (options.defaultBaseUrl !== undefined) {
|
|
316
317
|
baseUrl = baseUrl ?? ($env.OPENAI_BASE_URL?.trim() || options.defaultBaseUrl);
|
|
317
318
|
}
|
|
319
|
+
// Attribute xAI traffic as omp unless a User-Agent is already set.
|
|
320
|
+
if (model.provider === "xai" || model.provider === "xai-oauth") {
|
|
321
|
+
setHeaderIfAbsent(headers, "User-Agent", USER_AGENT);
|
|
322
|
+
}
|
|
318
323
|
const requestHeaders = { ...headers };
|
|
319
324
|
// A keyless provider (`auth: none` in models.yml) resolves to the `N/A`
|
|
320
325
|
// sentinel rather than a real key. Injecting `Authorization: Bearer N/A`
|
|
@@ -3308,7 +3313,7 @@ export function applyCommonResponsesSamplingParams<P extends CommonResponsesPara
|
|
|
3308
3313
|
params: P,
|
|
3309
3314
|
options: CommonSamplingOptions | undefined,
|
|
3310
3315
|
model: Pick<Model, "provider" | "api" | "id" | "omitMaxOutputTokens" | "maxTokens"> & {
|
|
3311
|
-
compat: Pick<ResolvedOpenAISharedCompat, "supportsSamplingParams">;
|
|
3316
|
+
compat: Pick<ResolvedOpenAISharedCompat, "supportsSamplingParams" | "supportsPenaltyAndStopParams">;
|
|
3312
3317
|
},
|
|
3313
3318
|
): void {
|
|
3314
3319
|
if (options?.maxTokens && !model.omitMaxOutputTokens) {
|
|
@@ -3325,8 +3330,10 @@ export function applyCommonResponsesSamplingParams<P extends CommonResponsesPara
|
|
|
3325
3330
|
if (options?.topP !== undefined) params.top_p = options.topP;
|
|
3326
3331
|
if (options?.topK !== undefined) params.top_k = options.topK;
|
|
3327
3332
|
if (options?.minP !== undefined) params.min_p = options.minP;
|
|
3328
|
-
if (
|
|
3329
|
-
|
|
3333
|
+
if (model.compat.supportsPenaltyAndStopParams) {
|
|
3334
|
+
if (options?.presencePenalty !== undefined) params.presence_penalty = options.presencePenalty;
|
|
3335
|
+
if (options?.repetitionPenalty !== undefined) params.repetition_penalty = options.repetitionPenalty;
|
|
3336
|
+
}
|
|
3330
3337
|
}
|
|
3331
3338
|
applyOpenAIServiceTier(params, options?.serviceTier, model);
|
|
3332
3339
|
}
|
|
@@ -4,7 +4,7 @@ import type { OAuthController, OAuthCredentials, OAuthLoginCallbacks } from "./o
|
|
|
4
4
|
import type { ProviderDefinition } from "./types";
|
|
5
5
|
|
|
6
6
|
const DEFAULT_AUTH_URL = "https://modelstudio.console.alibabacloud.com/";
|
|
7
|
-
const CHINA_AUTH_URL = "https://
|
|
7
|
+
const CHINA_AUTH_URL = "https://bailian.console.aliyun.com/?tab=model#/api-key";
|
|
8
8
|
const DEFAULT_API_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1";
|
|
9
9
|
const CHINA_API_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1";
|
|
10
10
|
const VALIDATION_MODEL = "qwen3.5-plus";
|
|
@@ -32,7 +32,7 @@ export async function loginAlibabaCodingPlan(options: OAuthController): Promise<
|
|
|
32
32
|
if (choice === "2") {
|
|
33
33
|
baseUrl = CHINA_API_BASE_URL;
|
|
34
34
|
authUrl = CHINA_AUTH_URL;
|
|
35
|
-
instructions = "Copy your API key from the Alibaba Cloud
|
|
35
|
+
instructions = "Copy your API key from the Alibaba Cloud Bailian console (China mainland)";
|
|
36
36
|
} else if (choice === "3") {
|
|
37
37
|
const customUrl = await options.onPrompt({
|
|
38
38
|
message: "Enter custom base URL",
|
|
@@ -10,6 +10,7 @@ import { scheduler } from "node:timers/promises";
|
|
|
10
10
|
import { $env, getAgentDir } from "@oh-my-pi/pi-utils";
|
|
11
11
|
import packageJson from "../../../package.json" with { type: "json" };
|
|
12
12
|
import * as AIError from "../../error";
|
|
13
|
+
import { isRecord } from "../../utils";
|
|
13
14
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
14
15
|
|
|
15
16
|
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
|
|
@@ -172,10 +173,29 @@ function parseTokenPayload(payload: TokenResponse, refreshTokenFallback?: string
|
|
|
172
173
|
});
|
|
173
174
|
}
|
|
174
175
|
|
|
176
|
+
let accountId: string | undefined;
|
|
177
|
+
const tokenParts = payload.access_token.split(".");
|
|
178
|
+
const jwtPayloadPart = tokenParts.length === 3 ? tokenParts[1] : undefined;
|
|
179
|
+
if (jwtPayloadPart) {
|
|
180
|
+
try {
|
|
181
|
+
const jwtPayload: unknown = JSON.parse(
|
|
182
|
+
new TextDecoder("utf-8").decode(Uint8Array.fromBase64(jwtPayloadPart, { alphabet: "base64url" })),
|
|
183
|
+
);
|
|
184
|
+
if (isRecord(jwtPayload)) {
|
|
185
|
+
const userId = typeof jwtPayload.user_id === "string" ? jwtPayload.user_id.trim() : "";
|
|
186
|
+
const subject = typeof jwtPayload.sub === "string" ? jwtPayload.sub.trim() : "";
|
|
187
|
+
accountId = userId || subject || undefined;
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
// Opaque access tokens remain valid credentials without account metadata.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
175
194
|
return {
|
|
176
195
|
access: payload.access_token,
|
|
177
196
|
refresh,
|
|
178
197
|
expires: Date.now() + payload.expires_in * 1000 - OAUTH_EXPIRY_SKEW_MS,
|
|
198
|
+
accountId,
|
|
179
199
|
};
|
|
180
200
|
}
|
|
181
201
|
|
package/src/stream.ts
CHANGED
|
@@ -1022,45 +1022,47 @@ function streamDispatch<TApi extends Api>(
|
|
|
1022
1022
|
}
|
|
1023
1023
|
}
|
|
1024
1024
|
|
|
1025
|
-
/**
|
|
1026
|
-
const
|
|
1025
|
+
/** Maximum guarded attempts for a detected thinking loop. */
|
|
1026
|
+
const THINKING_LOOP_MAX_ATTEMPTS = 3;
|
|
1027
1027
|
const THINKING_LOOP_RETRY_BASE_DELAY_MS = 500;
|
|
1028
1028
|
const THINKING_LOOP_RETRY_MAX_DELAY_MS = 8_000;
|
|
1029
1029
|
|
|
1030
|
+
function isRetryableThinkingLoop(message: AssistantMessage): boolean {
|
|
1031
|
+
return (
|
|
1032
|
+
message.stopReason === "error" &&
|
|
1033
|
+
message.content.length === 0 &&
|
|
1034
|
+
AIError.is(message.errorId, AIError.Flag.ThinkingLoop)
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1030
1038
|
/**
|
|
1031
|
-
* Resolve a completion, re-sampling a thinking-loop stall
|
|
1032
|
-
* {@link
|
|
1033
|
-
*
|
|
1034
|
-
*
|
|
1035
|
-
*
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1038
|
-
*
|
|
1039
|
+
* Resolve a completion, re-sampling a thinking-loop stall for at most
|
|
1040
|
+
* {@link THINKING_LOOP_MAX_ATTEMPTS} guarded attempts. The loop guard raises an
|
|
1041
|
+
* empty `stopReason: "error"` stall; after the budget is spent that error is
|
|
1042
|
+
* returned unchanged. Detection is never disabled as a fallback, because an
|
|
1043
|
+
* unguarded retry can consume the remaining output budget and persist runaway
|
|
1044
|
+
* content. Non-stall results, including genuine errors, return immediately. A
|
|
1045
|
+
* caller abort during backoff propagates so cancellation surfaces as an abort,
|
|
1046
|
+
* never a stale stall result.
|
|
1039
1047
|
*/
|
|
1040
|
-
async function
|
|
1048
|
+
async function resolveWithThinkingLoopRetries(
|
|
1041
1049
|
signal: AbortSignal | undefined,
|
|
1042
1050
|
dispatch: () => AssistantMessageEventStream,
|
|
1043
|
-
cook: () => AssistantMessageEventStream,
|
|
1044
1051
|
): Promise<AssistantMessage> {
|
|
1045
1052
|
let message = await dispatch().result();
|
|
1046
|
-
let thinkingLoopRetry =
|
|
1047
|
-
for (let attempt =
|
|
1053
|
+
let thinkingLoopRetry = isRetryableThinkingLoop(message);
|
|
1054
|
+
for (let attempt = 1; thinkingLoopRetry && attempt < THINKING_LOOP_MAX_ATTEMPTS; attempt += 1) {
|
|
1048
1055
|
// A caller abort surfaces as a thrown abort (never the stall, which would
|
|
1049
1056
|
// misclassify as a 502): throwIfAborted before backoff, and scheduler.wait
|
|
1050
1057
|
// rejects if the abort lands mid-delay.
|
|
1051
1058
|
signal?.throwIfAborted();
|
|
1052
|
-
const delay = Math.min(THINKING_LOOP_RETRY_BASE_DELAY_MS * 2 ** attempt, THINKING_LOOP_RETRY_MAX_DELAY_MS);
|
|
1059
|
+
const delay = Math.min(THINKING_LOOP_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), THINKING_LOOP_RETRY_MAX_DELAY_MS);
|
|
1053
1060
|
await scheduler.wait(delay, { signal });
|
|
1054
1061
|
message = await dispatch().result();
|
|
1055
|
-
thinkingLoopRetry =
|
|
1056
|
-
message.stopReason === "error" &&
|
|
1057
|
-
message.content.length === 0 &&
|
|
1058
|
-
AIError.is(message.errorId, AIError.Flag.ThinkingLoop);
|
|
1062
|
+
thinkingLoopRetry = isRetryableThinkingLoop(message);
|
|
1059
1063
|
}
|
|
1060
|
-
if (
|
|
1061
|
-
|
|
1062
|
-
// Abort budget spent and still looping: let it cook with the guard disabled.
|
|
1063
|
-
return cook().result();
|
|
1064
|
+
if (thinkingLoopRetry) signal?.throwIfAborted();
|
|
1065
|
+
return message;
|
|
1064
1066
|
}
|
|
1065
1067
|
|
|
1066
1068
|
export async function complete<TApi extends Api>(
|
|
@@ -1068,11 +1070,7 @@ export async function complete<TApi extends Api>(
|
|
|
1068
1070
|
context: Context,
|
|
1069
1071
|
options?: OptionsForApi<TApi>,
|
|
1070
1072
|
): Promise<AssistantMessage> {
|
|
1071
|
-
return
|
|
1072
|
-
options?.signal,
|
|
1073
|
-
() => stream(model, context, options),
|
|
1074
|
-
() => stream(model, context, { ...options, loopGuard: { ...options?.loopGuard, enabled: false } }),
|
|
1075
|
-
);
|
|
1073
|
+
return resolveWithThinkingLoopRetries(options?.signal, () => stream(model, context, options));
|
|
1076
1074
|
}
|
|
1077
1075
|
|
|
1078
1076
|
type AuthRetryFailure = {
|
|
@@ -1578,23 +1576,27 @@ function streamSimpleRequest<TApi extends Api>(
|
|
|
1578
1576
|
|
|
1579
1577
|
// GitLab Duo - wraps Anthropic/OpenAI behind GitLab AI Gateway direct access tokens
|
|
1580
1578
|
if (isGitLabDuoModel(model)) {
|
|
1581
|
-
return
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1579
|
+
return withThinkingLoopGuard(model, requestOptions, opts =>
|
|
1580
|
+
withProviderInFlightLimit(model, opts, () =>
|
|
1581
|
+
streamGitLabDuo(model, context, {
|
|
1582
|
+
...opts,
|
|
1583
|
+
apiKey,
|
|
1584
|
+
}),
|
|
1585
|
+
),
|
|
1586
1586
|
);
|
|
1587
1587
|
}
|
|
1588
1588
|
|
|
1589
1589
|
// GitLab Duo Workflow - IDE workflow protocol + WebSocket action bridge
|
|
1590
1590
|
if (model.api === "gitlab-duo-agent") {
|
|
1591
1591
|
// Does not route through withProviderInFlightLimit, so heal explicitly.
|
|
1592
|
-
return
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1592
|
+
return withThinkingLoopGuard(model, requestOptions, opts =>
|
|
1593
|
+
healLeakedThinking(
|
|
1594
|
+
model,
|
|
1595
|
+
streamGitLabDuoWorkflow(model as Model<"gitlab-duo-agent">, context, {
|
|
1596
|
+
...opts,
|
|
1597
|
+
apiKey,
|
|
1598
|
+
}),
|
|
1599
|
+
),
|
|
1598
1600
|
);
|
|
1599
1601
|
}
|
|
1600
1602
|
|
|
@@ -1606,24 +1608,28 @@ function streamSimpleRequest<TApi extends Api>(
|
|
|
1606
1608
|
// thinking, so clamp disabled requests to the lowest supported effort
|
|
1607
1609
|
// (mirrors the mapOptionsForApi path every other provider takes).
|
|
1608
1610
|
const kimiOptions = normalizeMandatoryReasoningOptions(model, requestOptions);
|
|
1609
|
-
return
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1611
|
+
return withThinkingLoopGuard(model, kimiOptions, opts =>
|
|
1612
|
+
withProviderInFlightLimit(model, opts, () =>
|
|
1613
|
+
streamKimi(model as Model<"openai-completions">, context, {
|
|
1614
|
+
...opts,
|
|
1615
|
+
apiKey,
|
|
1616
|
+
format: opts?.kimiApiFormat,
|
|
1617
|
+
}),
|
|
1618
|
+
),
|
|
1615
1619
|
);
|
|
1616
1620
|
}
|
|
1617
1621
|
|
|
1618
1622
|
// Synthetic - route to dedicated handler that wraps OpenAI or Anthropic API
|
|
1619
1623
|
if (isSyntheticModel(model)) {
|
|
1620
|
-
// Pass raw SimpleStreamOptions - streamSynthetic handles mapping internally
|
|
1621
|
-
return
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1624
|
+
// Pass raw SimpleStreamOptions - streamSynthetic handles mapping internally.
|
|
1625
|
+
return withThinkingLoopGuard(model, requestOptions, opts =>
|
|
1626
|
+
withProviderInFlightLimit(model, opts, () =>
|
|
1627
|
+
streamSynthetic(model as Model<"openai-completions">, context, {
|
|
1628
|
+
...opts,
|
|
1629
|
+
apiKey,
|
|
1630
|
+
format: opts?.syntheticApiFormat ?? "openai",
|
|
1631
|
+
}),
|
|
1632
|
+
),
|
|
1627
1633
|
);
|
|
1628
1634
|
}
|
|
1629
1635
|
const providerOptions = mapOptionsForApi(model, requestOptions, apiKey);
|
|
@@ -1635,11 +1641,7 @@ export async function completeSimple<TApi extends Api>(
|
|
|
1635
1641
|
context: Context,
|
|
1636
1642
|
options?: SimpleStreamOptions,
|
|
1637
1643
|
): Promise<AssistantMessage> {
|
|
1638
|
-
return
|
|
1639
|
-
options?.signal,
|
|
1640
|
-
() => streamSimple(model, context, options),
|
|
1641
|
-
() => streamSimple(model, context, { ...options, loopGuard: { ...options?.loopGuard, enabled: false } }),
|
|
1642
|
-
);
|
|
1644
|
+
return resolveWithThinkingLoopRetries(options?.signal, () => streamSimple(model, context, options));
|
|
1643
1645
|
}
|
|
1644
1646
|
|
|
1645
1647
|
const MIN_OUTPUT_TOKENS = 1024;
|
package/src/types.ts
CHANGED
|
@@ -716,8 +716,9 @@ export interface AnthropicFallbackContent {
|
|
|
716
716
|
}
|
|
717
717
|
|
|
718
718
|
/**
|
|
719
|
-
* Verbatim Anthropic web-search call/result retained for
|
|
720
|
-
* history replay. Other providers discard it in
|
|
719
|
+
* Verbatim Anthropic web-search or tool-search call/result retained for
|
|
720
|
+
* same-provider history replay. Other providers discard it in
|
|
721
|
+
* `transformMessages`.
|
|
721
722
|
*/
|
|
722
723
|
export interface AnthropicServerToolContent {
|
|
723
724
|
type: "anthropicServerTool";
|
|
@@ -725,12 +726,12 @@ export interface AnthropicServerToolContent {
|
|
|
725
726
|
| {
|
|
726
727
|
type: "server_tool_use";
|
|
727
728
|
id: string;
|
|
728
|
-
name: "web_search";
|
|
729
|
+
name: "web_search" | "tool_search_tool_regex" | "tool_search_tool_bm25";
|
|
729
730
|
input?: Record<string, unknown> | null;
|
|
730
731
|
[key: string]: unknown;
|
|
731
732
|
}
|
|
732
733
|
| {
|
|
733
|
-
type: "web_search_tool_result";
|
|
734
|
+
type: "web_search_tool_result" | "tool_search_tool_result";
|
|
734
735
|
tool_use_id: string;
|
|
735
736
|
content: unknown;
|
|
736
737
|
[key: string]: unknown;
|