@oh-my-pi/pi-ai 17.2.11 → 17.2.13

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 (79) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/types/auth-retry.d.ts +3 -3
  3. package/dist/types/auth-storage.d.ts +2 -0
  4. package/dist/types/error/auth-classify.d.ts +5 -5
  5. package/dist/types/error/aws.d.ts +5 -1
  6. package/dist/types/error/flags.d.ts +4 -0
  7. package/dist/types/providers/aws-credentials.d.ts +4 -3
  8. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  9. package/dist/types/providers/cursor-pi-args.d.ts +14 -0
  10. package/dist/types/providers/openai-shared.d.ts +9 -1
  11. package/dist/types/registry/api-key-login.d.ts +6 -4
  12. package/dist/types/registry/cloudflare-ai-gateway.d.ts +2 -2
  13. package/dist/types/registry/kagi.d.ts +2 -2
  14. package/dist/types/registry/litellm.d.ts +2 -2
  15. package/dist/types/registry/llama-cpp.d.ts +2 -2
  16. package/dist/types/registry/lm-studio.d.ts +2 -2
  17. package/dist/types/registry/parallel.d.ts +2 -2
  18. package/dist/types/registry/tavily.d.ts +1 -1
  19. package/dist/types/registry/vercel-ai-gateway.d.ts +2 -2
  20. package/dist/types/registry/vllm.d.ts +2 -2
  21. package/dist/types/types.d.ts +7 -0
  22. package/dist/types/usage/cursor.d.ts +11 -0
  23. package/dist/types/usage/openai-codex-reset.d.ts +0 -20
  24. package/dist/types/usage/shared.d.ts +13 -1
  25. package/dist/types/utils/block-symbols.d.ts +12 -0
  26. package/package.json +5 -5
  27. package/src/auth-retry.ts +5 -4
  28. package/src/auth-storage.ts +77 -33
  29. package/src/dialect/owned-stream.ts +3 -0
  30. package/src/error/auth-classify.ts +7 -6
  31. package/src/error/aws.ts +5 -1
  32. package/src/error/flags.ts +14 -0
  33. package/src/providers/amazon-bedrock.ts +38 -0
  34. package/src/providers/aws-credentials.ts +222 -29
  35. package/src/providers/cursor/exec-modern.ts +1 -0
  36. package/src/providers/cursor-pi-args.ts +22 -0
  37. package/src/providers/cursor.ts +81 -1
  38. package/src/providers/google-gemini-cli.ts +49 -15
  39. package/src/providers/google-shared.ts +7 -1
  40. package/src/providers/openai-codex/request-transformer.ts +38 -17
  41. package/src/providers/openai-codex-responses.ts +2 -3
  42. package/src/providers/openai-responses.ts +4 -0
  43. package/src/providers/openai-shared.ts +55 -1
  44. package/src/providers/pi-native-server.ts +1 -0
  45. package/src/providers/register-builtins.ts +18 -14
  46. package/src/registry/api-key-login.ts +26 -12
  47. package/src/registry/aws.ts +13 -6
  48. package/src/registry/cloudflare-ai-gateway.ts +10 -29
  49. package/src/registry/kagi.ts +11 -29
  50. package/src/registry/litellm.ts +11 -29
  51. package/src/registry/llama-cpp.ts +11 -21
  52. package/src/registry/lm-studio.ts +9 -20
  53. package/src/registry/oauth/callback-server.ts +93 -5
  54. package/src/registry/parallel.ts +10 -28
  55. package/src/registry/tavily.ts +9 -27
  56. package/src/registry/vercel-ai-gateway.ts +10 -28
  57. package/src/registry/vllm.ts +11 -21
  58. package/src/stream.ts +10 -7
  59. package/src/types.ts +7 -0
  60. package/src/usage/alibaba-token-plan.ts +8 -15
  61. package/src/usage/claude.ts +12 -19
  62. package/src/usage/cursor.ts +179 -59
  63. package/src/usage/gemini.ts +3 -2
  64. package/src/usage/github-copilot.ts +3 -2
  65. package/src/usage/google-antigravity.ts +10 -17
  66. package/src/usage/kimi.ts +35 -17
  67. package/src/usage/minimax-code.ts +7 -27
  68. package/src/usage/openai-codex-reset.ts +3 -2
  69. package/src/usage/openai-codex.ts +5 -5
  70. package/src/usage/opencode-go.ts +1 -2
  71. package/src/usage/shared.ts +33 -10
  72. package/src/usage/synthetic.ts +4 -11
  73. package/src/usage/umans.ts +2 -2
  74. package/src/usage/xai-oauth.ts +13 -26
  75. package/src/usage/zai.ts +4 -6
  76. package/src/utils/aws-profile.ts +39 -1
  77. package/src/utils/block-symbols.ts +18 -0
  78. package/src/utils/leaked-thinking-stream.ts +3 -0
  79. package/src/utils/openrouter-headers.ts +3 -3
@@ -1,5 +1,5 @@
1
- import * as AIError from "../error";
2
- import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
1
+ import { createApiKeyLogin } from "./api-key-login";
2
+ import type { OAuthLoginCallbacks } from "./oauth/types";
3
3
  import type { ProviderDefinition } from "./types";
4
4
 
5
5
  const AUTH_URL = "https://docs.litellm.ai/docs/proxy/deploy";
@@ -10,33 +10,15 @@ const AUTH_URL = "https://docs.litellm.ai/docs/proxy/deploy";
10
10
  * Opens browser to LiteLLM setup docs, prompts user to paste their API key.
11
11
  * Returns the API key directly (not OAuthCredentials - this isn't OAuth).
12
12
  */
13
- export async function loginLiteLLM(options: OAuthController): Promise<string> {
14
- if (!options.onPrompt) {
15
- throw new AIError.OnPromptRequiredError("LiteLLM");
16
- }
17
-
18
- options.onAuth?.({
19
- url: AUTH_URL,
20
- instructions:
21
- "Run LiteLLM proxy (default http://localhost:4000/v1; set LITELLM_BASE_URL to customize it), then copy your master key or virtual key",
22
- });
23
-
24
- const apiKey = await options.onPrompt({
25
- message: "Paste your LiteLLM API key (master key or virtual key)",
26
- placeholder: "sk-...",
27
- });
28
-
29
- if (options.signal?.aborted) {
30
- throw new AIError.LoginCancelledError();
31
- }
32
-
33
- const trimmed = apiKey.trim();
34
- if (!trimmed) {
35
- throw new AIError.ApiKeyRequiredError();
36
- }
37
-
38
- return trimmed;
39
- }
13
+ export const loginLiteLLM = createApiKeyLogin({
14
+ providerLabel: "LiteLLM",
15
+ authUrl: AUTH_URL,
16
+ instructions:
17
+ "Run LiteLLM proxy (default http://localhost:4000/v1; set LITELLM_BASE_URL to customize it), then copy your master key or virtual key",
18
+ promptMessage: "Paste your LiteLLM API key (master key or virtual key)",
19
+ placeholder: "sk-...",
20
+ validation: null,
21
+ });
40
22
 
41
23
  export const litellmProvider = {
42
24
  id: "litellm",
@@ -1,5 +1,5 @@
1
- import * as AIError from "../error";
2
- import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
1
+ import { createApiKeyLogin } from "./api-key-login";
2
+ import type { OAuthLoginCallbacks } from "./oauth/types";
3
3
  import type { ProviderDefinition } from "./types";
4
4
 
5
5
  const PROVIDER_ID = "llama.cpp";
@@ -7,25 +7,15 @@ const AUTH_URL = "https://github.com/ggml-org/llama.cpp#quick-start";
7
7
  const DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:8080";
8
8
  const DEFAULT_LOCAL_TOKEN = "llama-cpp-local";
9
9
 
10
- export async function loginLlamaCpp(options: OAuthController): Promise<string> {
11
- if (!options.onPrompt) {
12
- throw new AIError.OnPromptRequiredError(PROVIDER_ID);
13
- }
14
- options.onAuth?.({
15
- url: AUTH_URL,
16
- instructions: `Paste your llama.cpp API key if your server requires auth. Leave empty for local no-auth mode (default base URL: ${DEFAULT_LOCAL_BASE_URL}; set LLAMA_CPP_BASE_URL to customize).`,
17
- });
18
- const apiKey = await options.onPrompt({
19
- message: "Paste your llama.cpp API key (optional for local no-auth)",
20
- placeholder: DEFAULT_LOCAL_TOKEN,
21
- allowEmpty: true,
22
- });
23
- if (options.signal?.aborted) {
24
- throw new AIError.LoginCancelledError();
25
- }
26
- const trimmed = apiKey.trim();
27
- return trimmed || DEFAULT_LOCAL_TOKEN;
28
- }
10
+ export const loginLlamaCpp = createApiKeyLogin({
11
+ providerLabel: PROVIDER_ID,
12
+ authUrl: AUTH_URL,
13
+ instructions: `Paste your llama.cpp API key if your server requires auth. Leave empty for local no-auth mode (default base URL: ${DEFAULT_LOCAL_BASE_URL}; set LLAMA_CPP_BASE_URL to customize).`,
14
+ promptMessage: "Paste your llama.cpp API key (optional for local no-auth)",
15
+ placeholder: DEFAULT_LOCAL_TOKEN,
16
+ validation: null,
17
+ emptyKeyFallback: DEFAULT_LOCAL_TOKEN,
18
+ });
29
19
 
30
20
  export const llamaCppProvider = {
31
21
  id: PROVIDER_ID,
@@ -1,28 +1,17 @@
1
- import * as AIError from "../error";
2
- import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
1
+ import { createApiKeyLogin } from "./api-key-login";
2
+ import type { OAuthLoginCallbacks } from "./oauth/types";
3
3
  import type { ProviderDefinition } from "./types";
4
4
 
5
5
  const PROVIDER_ID = "lm-studio";
6
6
  export const DEFAULT_LOCAL_TOKEN = "lm-studio-local";
7
7
 
8
- export async function loginLmStudio(options: OAuthController): Promise<string> {
9
- if (!options.onPrompt) {
10
- throw new AIError.OnPromptRequiredError(PROVIDER_ID);
11
- }
12
-
13
- const apiKey = await options.onPrompt({
14
- message: "Optional: Paste LM Studio API key (to customize endpoint URL, set LM_STUDIO_BASE_URL env var)",
15
- placeholder: DEFAULT_LOCAL_TOKEN,
16
- allowEmpty: true,
17
- });
18
-
19
- if (options.signal?.aborted) {
20
- throw new AIError.LoginCancelledError();
21
- }
22
-
23
- const trimmed = apiKey.trim();
24
- return trimmed || DEFAULT_LOCAL_TOKEN;
25
- }
8
+ export const loginLmStudio = createApiKeyLogin({
9
+ providerLabel: PROVIDER_ID,
10
+ promptMessage: "Optional: Paste LM Studio API key (to customize endpoint URL, set LM_STUDIO_BASE_URL env var)",
11
+ placeholder: DEFAULT_LOCAL_TOKEN,
12
+ validation: null,
13
+ emptyKeyFallback: DEFAULT_LOCAL_TOKEN,
14
+ });
26
15
 
27
16
  export const lmStudioProvider = {
28
17
  id: "lm-studio",
@@ -17,6 +17,15 @@ import type { OAuthController, OAuthCredentials } from "./types";
17
17
  const DEFAULT_TIMEOUT = 300_000;
18
18
  const DEFAULT_HOSTNAME = "localhost";
19
19
  const CALLBACK_PATH = "/callback";
20
+ const IPV4_LOOPBACK = "127.0.0.1";
21
+ const IPV6_LOOPBACK = "::1";
22
+ /**
23
+ * How many times a random-port bind may be redrawn when the ephemeral port it
24
+ * landed on is already held on {@link IPV6_LOOPBACK} by that exact address.
25
+ * Small on purpose: each redraw picks a fresh port, so a repeat collision is
26
+ * vanishingly unlikely.
27
+ */
28
+ const IPV6_COMPANION_ATTEMPTS = 4;
20
29
  /**
21
30
  * Path served by {@link OAuthCallbackFlow} that 302-redirects to the pending
22
31
  * authorization URL. Kept out of {@link OAuthCallbackFlowOptions} because it
@@ -28,6 +37,27 @@ const LAUNCH_PATH = "/launch";
28
37
 
29
38
  export type CallbackResult = { code: string; state: string };
30
39
 
40
+ /**
41
+ * Subset of {@link Bun.Server} this flow depends on, so a `localhost` flow can
42
+ * hand back one listener per loopback address family while still looking like a
43
+ * single server to callers.
44
+ */
45
+ interface CallbackServer {
46
+ readonly port: Bun.Server<unknown>["port"];
47
+ stop: Bun.Server<unknown>["stop"];
48
+ }
49
+
50
+ /**
51
+ * Whether a failed bind means "another process already holds this port".
52
+ * Bun surfaces `EADDRINUSE` on the error's `code` where the platform reports
53
+ * it, and otherwise only in the message, so both are checked.
54
+ */
55
+ function isAddressInUse(error: unknown): boolean {
56
+ const code = (error as { code?: unknown } | null | undefined)?.code;
57
+ if (typeof code === "string") return code === "EADDRINUSE";
58
+ return error instanceof Error && /EADDRINUSE|in use/i.test(error.message);
59
+ }
60
+
31
61
  export interface OAuthCallbackFlowOptions {
32
62
  preferredPort: number;
33
63
  callbackPath?: string;
@@ -192,7 +222,7 @@ export abstract class OAuthCallbackFlow {
192
222
  */
193
223
  async #startCallbackServer(
194
224
  expectedState: string,
195
- ): Promise<{ server: Bun.Server<unknown>; redirectUri: string; launchUrl: string | undefined }> {
225
+ ): Promise<{ server: CallbackServer; redirectUri: string; launchUrl: string | undefined }> {
196
226
  try {
197
227
  const server = this.#createServer(this.preferredPort, expectedState);
198
228
  // `preferredPort: 0` opts into a random port — read the actual bound
@@ -233,7 +263,7 @@ export abstract class OAuthCallbackFlow {
233
263
  * but every callback flow uses TCP; a missing port here indicates a
234
264
  * configuration error rather than a fallback case.
235
265
  */
236
- #resolveServerPort(server: Bun.Server<unknown>): number {
266
+ #resolveServerPort(server: CallbackServer): number {
237
267
  const port = server.port;
238
268
  if (typeof port !== "number") {
239
269
  throw new AIError.ConfigurationError(
@@ -277,10 +307,68 @@ export abstract class OAuthCallbackFlow {
277
307
  }
278
308
 
279
309
  /**
280
- * Create HTTP server for OAuth callback.
310
+ * Create the HTTP listener(s) for the OAuth callback.
311
+ *
312
+ * `localhost` is not a single endpoint: it resolves to both
313
+ * {@link IPV4_LOOPBACK} and {@link IPV6_LOOPBACK}, and clients commonly try
314
+ * `::1` first. Binding only the IPv4 literal hands the authorization code to
315
+ * whatever holds the IPv6 loopback on the same port — a dev server on
316
+ * `*:3000` is the common case — which answers from its own routes while this
317
+ * flow waits out the full {@link DEFAULT_TIMEOUT}. Nothing detects it either:
318
+ * a specific-address bind coexists with another process's wildcard bind, so
319
+ * `Bun.serve` reports the port as free and the random-port fallback in
320
+ * {@link #startCallbackServer} never runs.
321
+ *
322
+ * Binding both loopback literals fixes the delivery rather than dodging it:
323
+ * the kernel routes a connection to the most specific matching bind, so our
324
+ * `::1` listener receives `localhost` traffic that would otherwise reach a
325
+ * process bound to the `::` wildcard. Both listeners answer the same routes,
326
+ * so which family the client resolves stops mattering.
327
+ *
328
+ * A genuine collision — another process on exactly this loopback address and
329
+ * port — still raises EADDRINUSE and reaches the caller's in-use policy. A
330
+ * host that cannot bind `::1` at all (IPv6 disabled, address unavailable) is
331
+ * not a collision: the IPv4 listener is the only reachable endpoint there, so
332
+ * it serves alone.
281
333
  */
282
- #createServer(port: number, expectedState: string): Bun.Server<unknown> {
283
- const hostname = this.callbackHostname === DEFAULT_HOSTNAME ? "127.0.0.1" : this.callbackHostname;
334
+ #createServer(port: number, expectedState: string): CallbackServer {
335
+ if (this.callbackHostname !== DEFAULT_HOSTNAME) {
336
+ return this.#serve(this.callbackHostname, port, expectedState);
337
+ }
338
+ for (let attempt = 0; ; attempt++) {
339
+ const primary = this.#serve(IPV4_LOOPBACK, port, expectedState);
340
+ const boundPort = primary.port;
341
+ // A non-TCP endpoint has no port for the companion to target;
342
+ // #resolveServerPort reports that case precisely.
343
+ if (typeof boundPort !== "number") return primary;
344
+ let companion: Bun.Server<unknown>;
345
+ try {
346
+ companion = this.#serve(IPV6_LOOPBACK, boundPort, expectedState);
347
+ } catch (cause) {
348
+ if (!isAddressInUse(cause)) return primary;
349
+ void primary.stop(true);
350
+ // A pinned port has no alternative, so surface it as in use and let
351
+ // the caller apply its fallback or diagnostic policy. A random port
352
+ // can just be redrawn, since only that one number clashed.
353
+ if (port !== 0 || attempt >= IPV6_COMPANION_ATTEMPTS) throw cause;
354
+ continue;
355
+ }
356
+ // One server to callers. The IPv4 listener stays authoritative for
357
+ // `port` because the companion was bound to the port it resolved.
358
+ return {
359
+ get port() {
360
+ return primary.port;
361
+ },
362
+ stop: (closeActiveConnections?: boolean) => {
363
+ void companion.stop(closeActiveConnections);
364
+ return primary.stop(closeActiveConnections);
365
+ },
366
+ };
367
+ }
368
+ }
369
+
370
+ /** Bind one loopback listener serving the callback and launch routes. */
371
+ #serve(hostname: string, port: number, expectedState: string): Bun.Server<unknown> {
284
372
  return Bun.serve({
285
373
  hostname,
286
374
  port,
@@ -1,5 +1,5 @@
1
- import * as AIError from "../error";
2
- import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
1
+ import { createApiKeyLogin } from "./api-key-login";
2
+ import type { OAuthLoginCallbacks } from "./oauth/types";
3
3
  import type { ProviderDefinition } from "./types";
4
4
 
5
5
  const AUTH_URL = "https://platform.parallel.ai/settings?tab=api-keys";
@@ -10,32 +10,14 @@ const AUTH_URL = "https://platform.parallel.ai/settings?tab=api-keys";
10
10
  * Opens browser to the API keys page, prompts the user to paste their API key,
11
11
  * and returns the API key directly.
12
12
  */
13
- export async function loginParallel(options: OAuthController): Promise<string> {
14
- if (!options.onPrompt) {
15
- throw new AIError.OnPromptRequiredError("Parallel");
16
- }
17
-
18
- options.onAuth?.({
19
- url: AUTH_URL,
20
- instructions: "Copy your Parallel API key from the Parallel settings page.",
21
- });
22
-
23
- const apiKey = await options.onPrompt({
24
- message: "Paste your Parallel API key",
25
- placeholder: "sk_...",
26
- });
27
-
28
- if (options.signal?.aborted) {
29
- throw new AIError.LoginCancelledError();
30
- }
31
-
32
- const trimmed = apiKey.trim();
33
- if (!trimmed) {
34
- throw new AIError.ApiKeyRequiredError();
35
- }
36
-
37
- return trimmed;
38
- }
13
+ export const loginParallel = createApiKeyLogin({
14
+ providerLabel: "Parallel",
15
+ authUrl: AUTH_URL,
16
+ instructions: "Copy your Parallel API key from the Parallel settings page.",
17
+ promptMessage: "Paste your Parallel API key",
18
+ placeholder: "sk_...",
19
+ validation: null,
20
+ });
39
21
 
40
22
  export const parallelProvider = {
41
23
  id: "parallel",
@@ -1,4 +1,4 @@
1
- import * as AIError from "../error";
1
+ import { createApiKeyLogin } from "./api-key-login";
2
2
  import type { OAuthLoginCallbacks } from "./oauth/types";
3
3
  import type { ProviderDefinition } from "./types";
4
4
 
@@ -10,32 +10,14 @@ const AUTH_URL = "https://app.tavily.com/home";
10
10
  * Opens browser to API keys page and prompts user to paste their API key.
11
11
  * Returns the API key directly (not OAuthCredentials - this isn't OAuth).
12
12
  */
13
- export async function loginTavily(options: OAuthLoginCallbacks): Promise<string> {
14
- if (!options.onPrompt) {
15
- throw new AIError.OnPromptRequiredError("Tavily");
16
- }
17
-
18
- options.onAuth?.({
19
- url: AUTH_URL,
20
- instructions: "Copy your Tavily API key from the API Keys page.",
21
- });
22
-
23
- const apiKey = await options.onPrompt({
24
- message: "Paste your Tavily API key",
25
- placeholder: "tvly-...",
26
- });
27
-
28
- if (options.signal?.aborted) {
29
- throw new AIError.LoginCancelledError();
30
- }
31
-
32
- const trimmed = apiKey.trim();
33
- if (!trimmed) {
34
- throw new AIError.ApiKeyRequiredError();
35
- }
36
-
37
- return trimmed;
38
- }
13
+ export const loginTavily = createApiKeyLogin({
14
+ providerLabel: "Tavily",
15
+ authUrl: AUTH_URL,
16
+ instructions: "Copy your Tavily API key from the API Keys page.",
17
+ promptMessage: "Paste your Tavily API key",
18
+ placeholder: "tvly-...",
19
+ validation: null,
20
+ });
39
21
 
40
22
  export const tavilyProvider = {
41
23
  id: "tavily",
@@ -1,35 +1,17 @@
1
- import * as AIError from "../error";
2
- import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
1
+ import { createApiKeyLogin } from "./api-key-login";
2
+ import type { OAuthLoginCallbacks } from "./oauth/types";
3
3
  import type { ProviderDefinition } from "./types";
4
4
 
5
5
  const AUTH_URL = "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway%2Fapi-keys&title=AI+Gateway+API+Keys";
6
6
 
7
- export async function loginVercelAiGateway(options: OAuthController): Promise<string> {
8
- if (!options.onPrompt) {
9
- throw new AIError.OnPromptRequiredError("Vercel AI Gateway");
10
- }
11
-
12
- options.onAuth?.({
13
- url: AUTH_URL,
14
- instructions: "Copy your Vercel AI Gateway API key from the Vercel dashboard",
15
- });
16
-
17
- const apiKey = await options.onPrompt({
18
- message: "Paste your Vercel AI Gateway API key",
19
- placeholder: "vck_...",
20
- });
21
-
22
- if (options.signal?.aborted) {
23
- throw new AIError.LoginCancelledError();
24
- }
25
-
26
- const trimmed = apiKey.trim();
27
- if (!trimmed) {
28
- throw new AIError.ApiKeyRequiredError();
29
- }
30
-
31
- return trimmed;
32
- }
7
+ export const loginVercelAiGateway = createApiKeyLogin({
8
+ providerLabel: "Vercel AI Gateway",
9
+ authUrl: AUTH_URL,
10
+ instructions: "Copy your Vercel AI Gateway API key from the Vercel dashboard",
11
+ promptMessage: "Paste your Vercel AI Gateway API key",
12
+ placeholder: "vck_...",
13
+ validation: null,
14
+ });
33
15
 
34
16
  export const vercelAiGatewayProvider = {
35
17
  id: "vercel-ai-gateway",
@@ -1,5 +1,5 @@
1
- import * as AIError from "../error";
2
- import type { OAuthController, OAuthLoginCallbacks, OAuthProvider } from "./oauth/types";
1
+ import { createApiKeyLogin } from "./api-key-login";
2
+ import type { OAuthLoginCallbacks, OAuthProvider } from "./oauth/types";
3
3
  import type { ProviderDefinition } from "./types";
4
4
 
5
5
  const PROVIDER_ID: OAuthProvider = "vllm";
@@ -7,25 +7,15 @@ const AUTH_URL = "https://docs.vllm.ai/en/latest/serving/openai_compatible_serve
7
7
  const DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:8000/v1";
8
8
  const DEFAULT_LOCAL_TOKEN = "vllm-local";
9
9
 
10
- export async function loginVllm(options: OAuthController): Promise<string> {
11
- if (!options.onPrompt) {
12
- throw new AIError.OnPromptRequiredError(PROVIDER_ID);
13
- }
14
- options.onAuth?.({
15
- url: AUTH_URL,
16
- instructions: `Paste your vLLM API key if your server requires auth. Leave empty for local no-auth mode (default base URL: ${DEFAULT_LOCAL_BASE_URL}).`,
17
- });
18
- const apiKey = await options.onPrompt({
19
- message: "Paste your vLLM API key (optional for local no-auth)",
20
- placeholder: DEFAULT_LOCAL_TOKEN,
21
- allowEmpty: true,
22
- });
23
- if (options.signal?.aborted) {
24
- throw new AIError.LoginCancelledError();
25
- }
26
- const trimmed = apiKey.trim();
27
- return trimmed || DEFAULT_LOCAL_TOKEN;
28
- }
10
+ export const loginVllm = createApiKeyLogin({
11
+ providerLabel: PROVIDER_ID,
12
+ authUrl: AUTH_URL,
13
+ instructions: `Paste your vLLM API key if your server requires auth. Leave empty for local no-auth mode (default base URL: ${DEFAULT_LOCAL_BASE_URL}).`,
14
+ promptMessage: "Paste your vLLM API key (optional for local no-auth)",
15
+ placeholder: DEFAULT_LOCAL_TOKEN,
16
+ validation: null,
17
+ emptyKeyFallback: DEFAULT_LOCAL_TOKEN,
18
+ });
29
19
 
30
20
  export const vllmProvider = {
31
21
  id: "vllm",
package/src/stream.ts CHANGED
@@ -990,17 +990,19 @@ function extractStatusFromAssistantError(message: AssistantMessage): number | un
990
990
  function isRetryableUpstreamError(error: unknown, status: number | undefined, message: string | undefined): boolean {
991
991
  // 401 means the credential is bad; 403 is its valid-token twin (access
992
992
  // denied by plan, model policy, or org restriction — a sibling account may
993
- // not share it). Usage-limit phrasing (Codex's
993
+ // not share it). Explicit account-scoped policy errors such as Codex
994
+ // `cyber_policy` are likewise rotatable: another account may carry the
995
+ // required approval. Usage-limit phrasing (Codex's
994
996
  // "You have hit your ChatGPT usage limit", Anthropic's "usage_limit_reached",
995
997
  // Google's "resource_exhausted", OpenAI's "insufficient_quota") and 429s
996
998
  // without transient rate-limit wording mean this account is parked but a
997
999
  // sibling credential can usually pick the request up. Both are rotatable
998
- // via `onAuthError` — the auth-gateway maps the former to
999
- // `invalidateCredentialMatching` and the latter to
1000
- // `markUsageLimitReached`. Transient 429s ("Too many requests",
1001
- // per-minute caps) classify as RATE_LIMIT_EXCEEDED in
1002
- // `parseRateLimitReason` and stay in the provider's own backoff layer
1003
- // instead of burning siblings.
1000
+ // via `onAuthError` — the auth-gateway maps hard auth failures to
1001
+ // `invalidateCredentialMatching` and temporary account constraints to a
1002
+ // credential block. Transient 429s ("Too many requests", per-minute caps)
1003
+ // classify as RATE_LIMIT_EXCEEDED in `parseRateLimitReason` and stay in the
1004
+ // provider's own backoff layer instead of burning siblings.
1005
+ if (AIError.isAccountPolicyError(error)) return true;
1004
1006
  if (AIError.isUsageLimit(error)) return true;
1005
1007
  if (isInvalidatedOAuthTokenError(error)) return true;
1006
1008
  if (status === 401 || (status === 403 && !isConcurrencyCapExclusion(status, message))) return true;
@@ -1506,6 +1508,7 @@ function mapOptionsForApi<TApi extends Api>(
1506
1508
  execHandlers: options?.execHandlers,
1507
1509
  fetch: options?.fetch,
1508
1510
  fallbacks: options?.fallbacks,
1511
+ acceptEmptyResponse: options?.acceptEmptyResponse,
1509
1512
  ...simpleProviderOptions,
1510
1513
  };
1511
1514
 
package/src/types.ts CHANGED
@@ -552,6 +552,13 @@ export interface StreamOptions {
552
552
  * Optional retry delay hook for tests and transports that need custom scheduling.
553
553
  */
554
554
  providerRetryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
555
+ /**
556
+ * Accept a Google `STOP` response with no visible text or tool call as a
557
+ * successful completion. Passive callers such as advisors use this because
558
+ * silence is a valid result; interactive agent turns retain empty-response
559
+ * retries by default. Ignored by non-Google providers.
560
+ */
561
+ acceptEmptyResponse?: boolean;
555
562
  /**
556
563
  * Optional `fetch` implementation override. Providers route every HTTP
557
564
  * request — direct calls, SDK clients, and retry helpers — through this
@@ -1,3 +1,4 @@
1
+ import { toNumber } from "@oh-my-pi/pi-catalog/utils";
1
2
  import { parseAlibabaTokenPlanCredential } from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan";
2
3
  import type {
3
4
  CredentialRankingStrategy,
@@ -8,7 +9,7 @@ import type {
8
9
  UsageReport,
9
10
  } from "../usage";
10
11
  import { isRecord } from "../utils";
11
- import { toNumber } from "./shared";
12
+ import { HOUR_MS, parsePositiveTimestamp, WEEK_MS } from "./shared";
12
13
 
13
14
  const PROVIDER = "alibaba-token-plan";
14
15
  const CONSOLE_ORIGIN = "https://home.qwencloud.com";
@@ -19,8 +20,6 @@ const USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage";
19
20
  const USAGE_URL = `https://cs-data.qwencloud.com/data/api.json?product=sfm_bailian&action=${GATEWAY_ACTION}&api=${encodeURIComponent(USAGE_API)}`;
20
21
  const BROWSER_USER_AGENT =
21
22
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36";
22
- const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
23
- const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
24
23
  const CONSOLE_CORNERSTONE_PARAM = {
25
24
  domain: "home.qwencloud.com",
26
25
  consoleSite: "QWENCLOUD",
@@ -55,12 +54,6 @@ function unwrapGatewayData(value: Record<string, unknown>): Record<string, unkno
55
54
  return current;
56
55
  }
57
56
 
58
- function parseResetTime(value: unknown): number | undefined {
59
- const parsed = toNumber(value);
60
- if (parsed === undefined || parsed <= 0) return undefined;
61
- return parsed < 1_000_000_000_000 ? parsed * 1000 : parsed;
62
- }
63
-
64
57
  function parseUsedFraction(value: unknown): number | undefined {
65
58
  const parsed = toNumber(value);
66
59
  if (parsed === undefined || parsed < 0) return undefined;
@@ -178,17 +171,17 @@ async function fetchAlibabaTokenPlanUsage(
178
171
  buildLimit(
179
172
  "5h",
180
173
  "5 Hour Credits",
181
- FIVE_HOURS_MS,
174
+ 5 * HOUR_MS,
182
175
  parseUsedFraction(responseData.per5HourPercentage),
183
- parseResetTime(responseData.per5HourResetTime),
176
+ parsePositiveTimestamp(responseData.per5HourResetTime),
184
177
  accountId,
185
178
  ),
186
179
  buildLimit(
187
180
  "7d",
188
181
  "7 Day Credits",
189
- SEVEN_DAYS_MS,
182
+ WEEK_MS,
190
183
  parseUsedFraction(responseData.per1WeekPercentage),
191
- parseResetTime(responseData.per1WeekResetTime),
184
+ parsePositiveTimestamp(responseData.per1WeekResetTime),
192
185
  accountId,
193
186
  ),
194
187
  ].filter((limit): limit is UsageLimit => limit !== undefined);
@@ -224,7 +217,7 @@ export const alibabaTokenPlanRankingStrategy: CredentialRankingStrategy = {
224
217
  secondary: report.limits.find(limit => limit.id === "credits:7d"),
225
218
  }),
226
219
  windowDefaults: {
227
- primaryMs: FIVE_HOURS_MS,
228
- secondaryMs: SEVEN_DAYS_MS,
220
+ primaryMs: 5 * HOUR_MS,
221
+ secondaryMs: WEEK_MS,
229
222
  },
230
223
  };