@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/types/auth-storage.d.ts +12 -2
  3. package/dist/types/error/rate-limit.d.ts +5 -2
  4. package/dist/types/index.d.ts +1 -0
  5. package/dist/types/oneshot-retry.d.ts +89 -0
  6. package/dist/types/providers/anthropic-wire.d.ts +18 -5
  7. package/dist/types/providers/openai-shared.d.ts +1 -1
  8. package/dist/types/types.d.ts +5 -4
  9. package/dist/types/usage/kimi.d.ts +3 -1
  10. package/dist/types/utils/schema/strict-tool-validation.d.ts +10 -1
  11. package/dist/types/utils/schema/wire.d.ts +6 -0
  12. package/dist/types/utils/thinking-loop.d.ts +12 -9
  13. package/package.json +5 -5
  14. package/src/auth-storage.ts +36 -5
  15. package/src/error/flags.ts +6 -2
  16. package/src/error/rate-limit.ts +31 -2
  17. package/src/index.ts +1 -0
  18. package/src/oneshot-retry.ts +230 -0
  19. package/src/providers/anthropic-messages-server.ts +5 -5
  20. package/src/providers/anthropic-wire.ts +31 -5
  21. package/src/providers/anthropic.ts +15 -4
  22. package/src/providers/cursor.ts +44 -4
  23. package/src/providers/openai-completions.ts +107 -63
  24. package/src/providers/openai-responses.ts +13 -10
  25. package/src/providers/openai-shared.ts +10 -3
  26. package/src/registry/alibaba-coding-plan.ts +2 -2
  27. package/src/registry/oauth/kimi.ts +20 -0
  28. package/src/stream.ts +59 -57
  29. package/src/types.ts +5 -4
  30. package/src/usage/kimi.ts +15 -0
  31. package/src/usage/umans.ts +93 -20
  32. package/src/utils/leaked-thinking-stream.ts +2 -2
  33. package/src/utils/schema/strict-tool-validation.ts +39 -5
  34. package/src/utils/schema/wire.ts +31 -0
  35. 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 (options?.presencePenalty !== undefined) {
1579
- params.presence_penalty = options.presencePenalty;
1580
- }
1581
- if (options?.repetitionPenalty !== undefined) {
1582
- params.repetition_penalty = options.repetitionPenalty;
1583
- }
1584
- if (options?.frequencyPenalty !== undefined) {
1585
- params.frequency_penalty = options.frequencyPenalty;
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 (params.tool_choice === "none" && (!Array.isArray(params.tools) || params.tools.length === 0)) {
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
- // Handle pipe-separated IDs from OpenAI Responses API
1815
- // Format: {call_id}|{id} where {id} can be 400+ chars with special chars (+, /, =)
1816
- // These come from providers like github-copilot, openai-codex, opencode
1817
- // Extract just the call_id part and normalize it
1818
- if (id.includes("|")) {
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 = toolWireSchema(tool);
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: adaptedTools.map(({ tool, baseParameters, parameters, strict }) => {
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
- model.provider === "xai-oauth"
1290
- ? options?.reasoning === undefined
1291
- ? undefined
1292
- : null
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(sanitized) as Record<string, unknown>)
1426
- : sanitized;
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
- const violation = findStrictToolSchemaViolation(parameters);
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 (options?.presencePenalty !== undefined) params.presence_penalty = options.presencePenalty;
3329
- if (options?.repetitionPenalty !== undefined) params.repetition_penalty = options.repetitionPenalty;
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://dashscope.console.aliyun.com/";
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 DashScope console (China mainland)";
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
- /** Thinking-loop re-samples spent before {@link resolveWithThinkingLoopCook} cooks. */
1026
- const THINKING_LOOP_MAX_ABORTS = 3;
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 up to
1032
- * {@link THINKING_LOOP_MAX_ABORTS} times before letting it cook. The loop guard
1033
- * raises an empty `stopReason: "error"` stall on each guarded attempt; this
1034
- * result-path consumer re-dispatches a fresh request per stall and, once the abort
1035
- * budget is spent, runs one final pass with the guard disabled so a stubborn loop
1036
- * returns the model's raw output instead of a fatal stall. Non-stall results —
1037
- * including genuine errors — return immediately; a caller abort during backoff
1038
- * propagates so cancellation surfaces as an abort, never a stale stall result.
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 resolveWithThinkingLoopCook(
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 = AIError.is(message.errorId, AIError.Flag.ThinkingLoop);
1047
- for (let attempt = 0; thinkingLoopRetry && attempt < THINKING_LOOP_MAX_ABORTS - 1; attempt += 1) {
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 (!thinkingLoopRetry) return message;
1061
- signal?.throwIfAborted();
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 resolveWithThinkingLoopCook(
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 withProviderInFlightLimit(model, requestOptions, () =>
1582
- streamGitLabDuo(model, context, {
1583
- ...requestOptions,
1584
- apiKey,
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 healLeakedThinking(
1593
- model,
1594
- streamGitLabDuoWorkflow(model as Model<"gitlab-duo-agent">, context, {
1595
- ...requestOptions,
1596
- apiKey,
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 withProviderInFlightLimit(model, kimiOptions, () =>
1610
- streamKimi(model as Model<"openai-completions">, context, {
1611
- ...kimiOptions,
1612
- apiKey,
1613
- format: kimiOptions?.kimiApiFormat,
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 withProviderInFlightLimit(model, requestOptions, () =>
1622
- streamSynthetic(model as Model<"openai-completions">, context, {
1623
- ...requestOptions,
1624
- apiKey,
1625
- format: requestOptions?.syntheticApiFormat ?? "openai", // Default to OpenAI format
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 resolveWithThinkingLoopCook(
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 same-provider
720
- * history replay. Other providers discard it in `transformMessages`.
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;