@oh-my-pi/pi-ai 16.1.13 → 16.1.15

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 (41) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/types/auth-retry.d.ts +6 -2
  3. package/dist/types/providers/aws-credentials.d.ts +2 -0
  4. package/dist/types/providers/openai-shared.d.ts +3 -0
  5. package/dist/types/rate-limit-utils.d.ts +26 -0
  6. package/dist/types/registry/huggingface.d.ts +2 -2
  7. package/dist/types/registry/nvidia.d.ts +0 -1
  8. package/dist/types/registry/oauth/minimax-code.d.ts +2 -3
  9. package/dist/types/registry/qianfan.d.ts +2 -2
  10. package/dist/types/registry/registry.d.ts +4 -1
  11. package/dist/types/registry/sakana.d.ts +7 -0
  12. package/dist/types/registry/types.d.ts +0 -2
  13. package/dist/types/registry/venice.d.ts +2 -2
  14. package/dist/types/registry/zai.d.ts +2 -2
  15. package/dist/types/registry/zhipu-coding-plan.d.ts +2 -2
  16. package/dist/types/utils/deterministic-id.d.ts +16 -0
  17. package/dist/types/utils/proxy.d.ts +29 -0
  18. package/package.json +4 -4
  19. package/src/auth-gateway/server.ts +7 -8
  20. package/src/auth-retry.ts +12 -7
  21. package/src/auth-storage.ts +7 -11
  22. package/src/providers/amazon-bedrock.ts +1 -0
  23. package/src/providers/aws-credentials.ts +23 -10
  24. package/src/providers/cursor.ts +12 -15
  25. package/src/providers/devin.ts +7 -14
  26. package/src/providers/openai-responses.ts +2 -1
  27. package/src/providers/openai-shared.ts +33 -6
  28. package/src/rate-limit-utils.ts +49 -1
  29. package/src/registry/huggingface.ts +13 -35
  30. package/src/registry/nvidia.ts +0 -1
  31. package/src/registry/oauth/minimax-code.ts +19 -48
  32. package/src/registry/qianfan.ts +12 -34
  33. package/src/registry/registry.ts +2 -0
  34. package/src/registry/sakana.ts +22 -0
  35. package/src/registry/types.ts +0 -2
  36. package/src/registry/venice.ts +12 -34
  37. package/src/registry/zai.ts +12 -33
  38. package/src/registry/zhipu-coding-plan.ts +12 -33
  39. package/src/stream.ts +29 -19
  40. package/src/utils/deterministic-id.ts +20 -0
  41. package/src/utils/proxy.ts +239 -0
@@ -42,6 +42,7 @@ import type {
42
42
  Tool,
43
43
  ToolCall,
44
44
  } from "../types";
45
+ import { deterministicUuid } from "../utils/deterministic-id";
45
46
  import { AssistantMessageEventStream } from "../utils/event-stream";
46
47
  import { parseStreamingJson } from "../utils/json-parse";
47
48
  import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
@@ -427,7 +428,7 @@ function buildDevinChatRequest(
427
428
  plannerMode: ConversationalPlannerMode.DEFAULT,
428
429
  toolChoice: create(ChatToolChoiceSchema, { choice: { case: "optionName", value: "auto" } }),
429
430
  systemPromptCacheOptions: create(PromptCacheOptionsSchema, { type: CacheControlType.EPHEMERAL }),
430
- disableParallelToolCalls: false,
431
+ disableParallelToolCalls: true,
431
432
  cascadeId,
432
433
  executionId: crypto.randomUUID(),
433
434
  configuration: create(CompletionConfigurationSchema, {
@@ -455,6 +456,8 @@ function buildDevinChatRequest(
455
456
  /** Map omp `Message` history onto Cascade `ChatMessagePrompt`s (USER / SYSTEM / TOOL channels). */
456
457
  function buildChatMessagePrompts(messages: Message[], cascadeId: string): ChatMessagePrompt[] {
457
458
  const prompts: ChatMessagePrompt[] = [];
459
+ // messageId seeds are `cascadeId\0index\0role[...]` — prompt text is excluded
460
+ // so ids stay stable across content edits / history rebuilds.
458
461
  for (const [index, msg] of messages.entries()) {
459
462
  if (msg.role === "user" || msg.role === "developer") {
460
463
  let promptText = "";
@@ -472,7 +475,7 @@ function buildChatMessagePrompts(messages: Message[], cascadeId: string): ChatMe
472
475
  }
473
476
  prompts.push(
474
477
  create(ChatMessagePromptSchema, {
475
- messageId: deterministicMessageId(`${cascadeId}\0${index}\0${msg.role}`),
478
+ messageId: deterministicUuid(`${cascadeId}\0${index}\0${msg.role}`),
476
479
  source: ChatMessageSource.USER,
477
480
  prompt: promptText,
478
481
  images,
@@ -501,7 +504,7 @@ function buildChatMessagePrompts(messages: Message[], cascadeId: string): ChatMe
501
504
  }
502
505
  prompts.push(
503
506
  create(ChatMessagePromptSchema, {
504
- messageId: msg.responseId ?? `bot-${deterministicMessageId(`${cascadeId}\0${index}\0assistant`)}`,
507
+ messageId: msg.responseId ?? `bot-${deterministicUuid(`${cascadeId}\0${index}\0assistant`)}`,
505
508
  source: ChatMessageSource.SYSTEM,
506
509
  prompt: promptText,
507
510
  thinking: thinkingText,
@@ -522,7 +525,7 @@ function buildChatMessagePrompts(messages: Message[], cascadeId: string): ChatMe
522
525
  }
523
526
  prompts.push(
524
527
  create(ChatMessagePromptSchema, {
525
- messageId: deterministicMessageId(`${cascadeId}\0${index}\0tool\0${msg.toolCallId}`),
528
+ messageId: deterministicUuid(`${cascadeId}\0${index}\0tool\0${msg.toolCallId}`),
526
529
  source: ChatMessageSource.TOOL,
527
530
  toolCallId: msg.toolCallId,
528
531
  toolResultIsError: msg.isError,
@@ -535,16 +538,6 @@ function buildChatMessagePrompts(messages: Message[], cascadeId: string): ChatMe
535
538
  return prompts;
536
539
  }
537
540
 
538
- /**
539
- * Deterministic UUID-shaped message id derived from a stable per-message seed.
540
- * Seed is `${cascadeId}\0${index}\0${role}[...]` — prompt text is intentionally
541
- * excluded so ids stay stable across content edits / history rebuilds.
542
- */
543
- function deterministicMessageId(seed: string): string {
544
- const hex = new Bun.CryptoHasher("sha256").update(seed).digest("hex");
545
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
546
- }
547
-
548
541
  /**
549
542
  * Parse a Connect end-of-stream JSON trailer and return a human-readable error
550
543
  * string when it carries `{ error: { code, message } }`, else `null`. The trailer
@@ -436,7 +436,8 @@ const streamOpenAIResponsesOnce = (
436
436
  ? buildOpenAIResponsesChainedParams(params, trailingScaffoldingItems, chainState)
437
437
  : { params };
438
438
  sentPreviousResponseId = chained.previousResponseId;
439
- const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs();
439
+ const idleTimeoutMs =
440
+ options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs(model.compat.streamIdleTimeoutMs);
440
441
  const firstEventTimeoutMs =
441
442
  options?.streamFirstEventTimeoutMs ?? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs);
442
443
  const requestTimeoutMs =
@@ -130,6 +130,17 @@ export interface OpenAIRequestSetup {
130
130
  requestHeaders: Record<string, string>;
131
131
  }
132
132
 
133
+ function normalizeSakanaRequestBaseUrl(baseUrl: string | undefined): string | undefined {
134
+ const value = baseUrl?.trim();
135
+ if (!value) return undefined;
136
+ const normalized = value.replace(/\/+$/, "");
137
+ return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
138
+ }
139
+
140
+ function resolveSakanaRequestBaseUrl(): string | undefined {
141
+ return normalizeSakanaRequestBaseUrl($env.SAKANA_BASE_URL) ?? normalizeSakanaRequestBaseUrl($env.FUGU_BASE_URL);
142
+ }
143
+
133
144
  export function resolveOpenAIRequestSetup(
134
145
  model: OpenAIRequestSetupModel,
135
146
  options: OpenAIRequestSetupOptions,
@@ -165,6 +176,12 @@ export function resolveOpenAIRequestSetup(
165
176
  baseUrl = moonshotBaseUrl;
166
177
  }
167
178
  }
179
+ if (model.provider === "sakana") {
180
+ const sakanaBaseUrl = resolveSakanaRequestBaseUrl();
181
+ if (sakanaBaseUrl) {
182
+ baseUrl = sakanaBaseUrl;
183
+ }
184
+ }
168
185
  if (model.provider === "github-copilot") {
169
186
  apiKey = parseGitHubCopilotApiKey(rawApiKey).accessToken;
170
187
  const copilot = buildCopilotDynamicHeaders({
@@ -2384,19 +2401,29 @@ export function populateResponsesUsageFromResponse(
2384
2401
  input_tokens_details?: {
2385
2402
  cached_tokens?: number | null;
2386
2403
  cache_write_tokens?: number | null;
2404
+ orchestration_input_tokens?: number | null;
2405
+ orchestration_input_cached_tokens?: number | null;
2406
+ } | null;
2407
+ output_tokens_details?: {
2408
+ reasoning_tokens?: number | null;
2409
+ orchestration_output_tokens?: number | null;
2387
2410
  } | null;
2388
- output_tokens_details?: { reasoning_tokens?: number | null } | null;
2389
2411
  }
2390
2412
  | null
2391
2413
  | undefined,
2392
2414
  ): void {
2393
2415
  if (!usage) return;
2416
+ const details = usage.input_tokens_details;
2417
+ const outputDetails = usage.output_tokens_details;
2418
+ const orchestrationInputTokens = details?.orchestration_input_tokens ?? 0;
2419
+ const orchestrationInputCachedTokens = details?.orchestration_input_cached_tokens ?? 0;
2420
+ const orchestrationOutputTokens = outputDetails?.orchestration_output_tokens ?? 0;
2394
2421
  const accounting = calculateOpenAIUsageAccounting({
2395
- promptTokens: usage.input_tokens ?? 0,
2396
- outputTokens: usage.output_tokens ?? 0,
2397
- cachedTokens: usage.input_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens ?? 0,
2398
- reasoningTokens: usage.output_tokens_details?.reasoning_tokens ?? 0,
2399
- cacheWriteOpenRouter: usage.input_tokens_details?.cache_write_tokens ?? undefined,
2422
+ promptTokens: (usage.input_tokens ?? 0) + orchestrationInputTokens,
2423
+ outputTokens: (usage.output_tokens ?? 0) + orchestrationOutputTokens,
2424
+ cachedTokens: (details?.cached_tokens ?? usage.prompt_cache_hit_tokens ?? 0) + orchestrationInputCachedTokens,
2425
+ reasoningTokens: outputDetails?.reasoning_tokens ?? 0,
2426
+ cacheWriteOpenRouter: details?.cache_write_tokens ?? undefined,
2400
2427
  cacheWriteDeepSeek: usage.prompt_cache_miss_tokens ?? undefined,
2401
2428
  hasDeepSeekCacheHitAndMiss:
2402
2429
  usage.prompt_cache_hit_tokens !== undefined && usage.prompt_cache_miss_tokens !== undefined,
@@ -100,7 +100,55 @@ export function calculateRateLimitBackoffMs(reason: RateLimitReason): number {
100
100
 
101
101
  /** Detect usage/quota limit errors in error messages (persistent, requires credential switch). */
102
102
  const USAGE_LIMIT_PATTERN =
103
- /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?exceeded|quota.?reached|resource.?exhausted|exhausted your capacity|quota will reset|insufficient.?balance/i;
103
+ /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?exceeded|quota.?reached|resource.?exhausted|exhausted your capacity|quota will reset|insufficient.?(?:balance|quota)/i;
104
+
105
+ /**
106
+ * HTTP status codes that, absent richer body classification, represent an
107
+ * account-local usage cap rather than a bad credential or a transient blip.
108
+ * Always combine with {@link isUsageLimitOutcome} when a message is available
109
+ * — a 429 carrying transient rate-limit wording is NOT a usage cap.
110
+ */
111
+ export function isUsageLimitStatus(status: number | undefined): boolean {
112
+ return status === 429;
113
+ }
114
+
115
+ /**
116
+ * Returns true for failures that should burn one credential and rotate to a
117
+ * sibling account. Decision tree:
118
+ *
119
+ * 1. Body matches {@link isUsageLimitError} (Codex `usage_limit_reached`,
120
+ * Anthropic account rate-limit, Google `resource_exhausted`, OpenAI
121
+ * `insufficient_quota`, …) → rotate.
122
+ * 2. Status is not 429 → backoff (caller's domain).
123
+ * 3. Body is absent or {@link isOpaqueStatusBody opaque} (just the status,
124
+ * empty JSON, HTTP framing only) → rotate conservatively: the server
125
+ * gave us nothing else to go on.
126
+ * 4. Body has content → defer to {@link parseRateLimitReason}. Only
127
+ * `QUOTA_EXHAUSTED` rotates; `RATE_LIMIT_EXCEEDED` (`Too many requests`,
128
+ * per-minute caps), `MODEL_CAPACITY_EXHAUSTED` (`Service overloaded`),
129
+ * `SERVER_ERROR`, and `UNKNOWN` (`Please retry in 5s`) stay in the
130
+ * provider's own backoff layer so transient 429s don't burn sibling
131
+ * credentials.
132
+ */
133
+ export function isUsageLimitOutcome(status: number | undefined, message: string | undefined): boolean {
134
+ if (message && isUsageLimitError(message)) return true;
135
+ if (!isUsageLimitStatus(status)) return false;
136
+ if (!message || isOpaqueStatusBody(message)) return true;
137
+ return parseRateLimitReason(message) === "QUOTA_EXHAUSTED";
138
+ }
139
+
140
+ /**
141
+ * A 429 body is opaque when it carries no signal beyond the status itself —
142
+ * empty, whitespace-only, the status digits with HTTP/JSON framing, or
143
+ * generic punctuation. Anything else (retry hints, capacity wording, error
144
+ * descriptions) is informative enough to defer to the classifier.
145
+ */
146
+ function isOpaqueStatusBody(message: string): boolean {
147
+ const cleaned = message
148
+ .replace(/\b429\b/g, "")
149
+ .replace(/\b(?:http|https|status|error|code|response|message)\b/gi, "");
150
+ return !/[a-z\d]{3,}/i.test(cleaned);
151
+ }
104
152
 
105
153
  export function isUsageLimitError(errorMessage: string): boolean {
106
154
  return USAGE_LIMIT_PATTERN.test(errorMessage) || ACCOUNT_RATE_LIMIT_PATTERN.test(errorMessage);
@@ -1,5 +1,5 @@
1
- import { validateOpenAICompatibleApiKey } from "./api-key-validation";
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 =
@@ -7,42 +7,20 @@ const AUTH_URL =
7
7
  const API_BASE_URL = "https://router.huggingface.co/v1";
8
8
  const VALIDATION_MODEL = "openai/gpt-oss-120b";
9
9
 
10
- export async function loginHuggingface(options: OAuthController): Promise<string> {
11
- if (!options.onPrompt) {
12
- throw new Error("Hugging Face login requires onPrompt callback");
13
- }
14
-
15
- options.onAuth?.({
16
- url: AUTH_URL,
17
- instructions:
18
- "Create/copy a token with Make calls to Inference Providers permission (usable as HUGGINGFACE_HUB_TOKEN or HF_TOKEN)",
19
- });
20
-
21
- const apiKey = await options.onPrompt({
22
- message: "Paste your Hugging Face token (HUGGINGFACE_HUB_TOKEN / HF_TOKEN)",
23
- placeholder: "hf_...",
24
- });
25
-
26
- if (options.signal?.aborted) {
27
- throw new Error("Login cancelled");
28
- }
29
-
30
- const trimmed = apiKey.trim();
31
- if (!trimmed) {
32
- throw new Error("API key is required");
33
- }
34
-
35
- options.onProgress?.("Validating API key...");
36
- await validateOpenAICompatibleApiKey({
10
+ export const loginHuggingface = createApiKeyLogin({
11
+ providerLabel: "Hugging Face",
12
+ authUrl: AUTH_URL,
13
+ instructions:
14
+ "Create/copy a token with Make calls to Inference Providers permission (usable as HUGGINGFACE_HUB_TOKEN or HF_TOKEN)",
15
+ promptMessage: "Paste your Hugging Face token (HUGGINGFACE_HUB_TOKEN / HF_TOKEN)",
16
+ placeholder: "hf_...",
17
+ validation: {
18
+ kind: "chat-completions",
37
19
  provider: "Hugging Face",
38
- apiKey: trimmed,
39
20
  baseUrl: API_BASE_URL,
40
21
  model: VALIDATION_MODEL,
41
- signal: options.signal,
42
- });
43
-
44
- return trimmed;
45
- }
22
+ },
23
+ });
46
24
 
47
25
  export const huggingfaceProvider = {
48
26
  id: "huggingface",
@@ -58,5 +58,4 @@ export const nvidiaProvider = {
58
58
  id: "nvidia",
59
59
  name: "NVIDIA",
60
60
  login: (cb: OAuthLoginCallbacks) => loginNvidia(cb),
61
- appendApiKeyLogin: true,
62
61
  } as const satisfies ProviderDefinition;
@@ -13,8 +13,7 @@
13
13
  * China: https://api.minimaxi.com/v1
14
14
  */
15
15
 
16
- import { validateOpenAICompatibleApiKey } from "../api-key-validation";
17
- import type { OAuthController } from "./types";
16
+ import { createApiKeyLogin } from "../api-key-login";
18
17
 
19
18
  const AUTH_URL_INTL = "https://platform.minimax.io/subscribe/token-plan";
20
19
  const AUTH_URL_CN = "https://platform.minimaxi.com/subscribe/token-plan";
@@ -22,61 +21,33 @@ const API_BASE_URL_INTL = "https://api.minimax.io/v1";
22
21
  const API_BASE_URL_CN = "https://api.minimaxi.com/v1";
23
22
  const VALIDATION_MODEL = "MiniMax-M3";
24
23
 
24
+ function createMiniMaxLogin(authUrl: string, baseUrl: string, provider: string) {
25
+ return createApiKeyLogin({
26
+ providerLabel: "MiniMax Token Plan",
27
+ authUrl,
28
+ instructions: "Subscribe to Token Plan and copy your API key",
29
+ promptMessage: "Paste your MiniMax Token Plan API key",
30
+ placeholder: "sk-...",
31
+ validation: {
32
+ kind: "chat-completions",
33
+ provider,
34
+ baseUrl,
35
+ model: VALIDATION_MODEL,
36
+ },
37
+ });
38
+ }
39
+
25
40
  /**
26
41
  * Login to MiniMax Token Plan (international).
27
42
  *
28
43
  * Opens browser to subscription page, prompts user to paste their API key.
29
44
  * Returns the API key directly (not OAuthCredentials - this isn't OAuth).
30
45
  */
31
- export async function loginMiniMaxCode(options: OAuthController): Promise<string> {
32
- return loginMiniMaxCodeWithBaseUrl(options, AUTH_URL_INTL, API_BASE_URL_INTL, "MiniMax Token Plan");
33
- }
34
-
35
- async function loginMiniMaxCodeWithBaseUrl(
36
- options: OAuthController,
37
- authUrl: string,
38
- baseUrl: string,
39
- providerName: string,
40
- ): Promise<string> {
41
- const fetchImpl = options.fetch ?? fetch;
42
- if (!options.onPrompt) {
43
- throw new Error("MiniMax Token Plan login requires onPrompt callback");
44
- }
45
- // Open browser to subscription page
46
- options.onAuth?.({
47
- url: authUrl,
48
- instructions: "Subscribe to Token Plan and copy your API key",
49
- });
50
- // Prompt user to paste their API key
51
- const apiKey = await options.onPrompt({
52
- message: "Paste your MiniMax Token Plan API key",
53
- placeholder: "sk-...",
54
- });
55
- if (options.signal?.aborted) {
56
- throw new Error("Login cancelled");
57
- }
58
- const trimmed = apiKey.trim();
59
- if (!trimmed) {
60
- throw new Error("API key is required");
61
- }
62
-
63
- options.onProgress?.("Validating API key...");
64
- await validateOpenAICompatibleApiKey({
65
- provider: providerName,
66
- apiKey: trimmed,
67
- baseUrl,
68
- model: VALIDATION_MODEL,
69
- signal: options.signal,
70
- fetch: fetchImpl,
71
- });
72
- return trimmed;
73
- }
46
+ export const loginMiniMaxCode = createMiniMaxLogin(AUTH_URL_INTL, API_BASE_URL_INTL, "MiniMax Token Plan");
74
47
 
75
48
  /**
76
49
  * Login to MiniMax Token Plan (China).
77
50
  *
78
51
  * Same flow as international but uses China endpoint.
79
52
  */
80
- export async function loginMiniMaxCodeCn(options: OAuthController): Promise<string> {
81
- return loginMiniMaxCodeWithBaseUrl(options, AUTH_URL_CN, API_BASE_URL_CN, "MiniMax Token Plan (China)");
82
- }
53
+ export const loginMiniMaxCodeCn = createMiniMaxLogin(AUTH_URL_CN, API_BASE_URL_CN, "MiniMax Token Plan (China)");
@@ -1,46 +1,24 @@
1
- import { validateOpenAICompatibleApiKey } from "./api-key-validation";
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://console.bce.baidu.com/qianfan/ais/console/apiKey";
6
6
  const API_BASE_URL = "https://qianfan.baidubce.com/v2";
7
7
  const VALIDATION_MODEL = "deepseek-v3.2";
8
8
 
9
- export async function loginQianfan(options: OAuthController): Promise<string> {
10
- if (!options.onPrompt) {
11
- throw new Error("Qianfan login requires onPrompt callback");
12
- }
13
-
14
- options.onAuth?.({
15
- url: AUTH_URL,
16
- instructions: "Copy your Qianfan API key from the console",
17
- });
18
-
19
- const apiKey = await options.onPrompt({
20
- message: "Paste your Qianfan API key",
21
- placeholder: "bce-v3/ALTAK-...",
22
- });
23
-
24
- if (options.signal?.aborted) {
25
- throw new Error("Login cancelled");
26
- }
27
-
28
- const trimmed = apiKey.trim();
29
- if (!trimmed) {
30
- throw new Error("API key is required");
31
- }
32
-
33
- options.onProgress?.("Validating API key...");
34
- await validateOpenAICompatibleApiKey({
9
+ export const loginQianfan = createApiKeyLogin({
10
+ providerLabel: "Qianfan",
11
+ authUrl: AUTH_URL,
12
+ instructions: "Copy your Qianfan API key from the console",
13
+ promptMessage: "Paste your Qianfan API key",
14
+ placeholder: "bce-v3/ALTAK-...",
15
+ validation: {
16
+ kind: "chat-completions",
35
17
  provider: "qianfan",
36
- apiKey: trimmed,
37
18
  baseUrl: API_BASE_URL,
38
19
  model: VALIDATION_MODEL,
39
- signal: options.signal,
40
- });
41
-
42
- return trimmed;
43
- }
20
+ },
21
+ });
44
22
 
45
23
  export const qianfanProvider = {
46
24
  id: "qianfan",
@@ -44,6 +44,7 @@ import { parallelProvider } from "./parallel";
44
44
  import { perplexityProvider } from "./perplexity";
45
45
  import { qianfanProvider } from "./qianfan";
46
46
  import { qwenPortalProvider } from "./qwen-portal";
47
+ import { sakanaProvider } from "./sakana";
47
48
  import { syntheticProvider } from "./synthetic";
48
49
  import { tavilyProvider } from "./tavily";
49
50
  import { togetherProvider } from "./together";
@@ -90,6 +91,7 @@ const ALL = [
90
91
  zhipuCodingPlanProvider,
91
92
  umansProvider,
92
93
  qwenPortalProvider,
94
+ sakanaProvider,
93
95
  minimaxCodeProvider,
94
96
  minimaxCodeCnProvider,
95
97
  xiaomiProvider,
@@ -0,0 +1,22 @@
1
+ import { createApiKeyLogin } from "./api-key-login";
2
+ import type { OAuthLoginCallbacks } from "./oauth/types";
3
+ import type { ProviderDefinition } from "./types";
4
+
5
+ export const loginSakana = createApiKeyLogin({
6
+ providerLabel: "Sakana AI",
7
+ authUrl: "https://console.sakana.ai/api-keys",
8
+ instructions: "Copy your API key from the Sakana AI console",
9
+ promptMessage: "Paste your Sakana AI API key",
10
+ placeholder: "sk-...",
11
+ validation: {
12
+ kind: "models-endpoint",
13
+ provider: "Sakana AI",
14
+ modelsUrl: "https://api.sakana.ai/v1/models",
15
+ },
16
+ });
17
+
18
+ export const sakanaProvider = {
19
+ id: "sakana",
20
+ name: "Sakana AI",
21
+ login: (cb: OAuthLoginCallbacks) => loginSakana(cb),
22
+ } as const satisfies ProviderDefinition;
@@ -44,8 +44,6 @@ export interface ProviderDefinition {
44
44
  readonly envKeys?: KeyResolver;
45
45
  // --- interactive login (OAuthProviderInterface-compatible) ---
46
46
  readonly login?: (callbacks: OAuthLoginCallbacks) => Promise<OAuthCredentials | string>;
47
- /** String-returning login appends API keys instead of replacing the provider's existing key. */
48
- readonly appendApiKeyLogin?: boolean;
49
47
  readonly refreshToken?: (credentials: OAuthCredentials) => Promise<OAuthCredentials>;
50
48
  readonly getApiKey?: (credentials: OAuthCredentials) => string;
51
49
  /** Store OAuth credentials under a different provider id (e.g. `openai-codex-device` ⇒ `openai-codex`). */
@@ -1,5 +1,5 @@
1
- import { validateOpenAICompatibleApiKey } from "./api-key-validation";
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://venice.ai/settings/api";
@@ -12,41 +12,19 @@ const VALIDATION_MODEL = "qwen3-4b";
12
12
  * Opens browser to API keys page, prompts user to paste their API key.
13
13
  * Returns the API key directly (not OAuthCredentials - this isn't OAuth).
14
14
  */
15
- export async function loginVenice(options: OAuthController): Promise<string> {
16
- if (!options.onPrompt) {
17
- throw new Error("Venice login requires onPrompt callback");
18
- }
19
-
20
- options.onAuth?.({
21
- url: AUTH_URL,
22
- instructions: "Copy your API key from the Venice dashboard",
23
- });
24
-
25
- const apiKey = await options.onPrompt({
26
- message: "Paste your Venice API key",
27
- placeholder: "vapi_...",
28
- });
29
-
30
- if (options.signal?.aborted) {
31
- throw new Error("Login cancelled");
32
- }
33
-
34
- const trimmed = apiKey.trim();
35
- if (!trimmed) {
36
- throw new Error("API key is required");
37
- }
38
-
39
- options.onProgress?.("Validating API key...");
40
- await validateOpenAICompatibleApiKey({
15
+ export const loginVenice = createApiKeyLogin({
16
+ providerLabel: "Venice",
17
+ authUrl: AUTH_URL,
18
+ instructions: "Copy your API key from the Venice dashboard",
19
+ promptMessage: "Paste your Venice API key",
20
+ placeholder: "vapi_...",
21
+ validation: {
22
+ kind: "chat-completions",
41
23
  provider: "Venice",
42
- apiKey: trimmed,
43
24
  baseUrl: API_BASE_URL,
44
25
  model: VALIDATION_MODEL,
45
- signal: options.signal,
46
- });
47
-
48
- return trimmed;
49
- }
26
+ },
27
+ });
50
28
 
51
29
  export const veniceProvider = {
52
30
  id: "venice",
@@ -1,45 +1,24 @@
1
- import { validateOpenAICompatibleApiKey } from "./api-key-validation";
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://z.ai/manage-apikey/apikey-list";
6
6
  const API_BASE_URL = "https://api.z.ai/api/coding/paas/v4";
7
7
  const VALIDATION_MODEL = "glm-5.2";
8
8
 
9
- export async function loginZai(options: OAuthController): Promise<string> {
10
- if (!options.onPrompt) {
11
- throw new Error("Z.AI login requires onPrompt callback");
12
- }
13
-
14
- options.onAuth?.({
15
- url: AUTH_URL,
16
- instructions: "Copy your API key from the dashboard",
17
- });
18
-
19
- const apiKey = await options.onPrompt({
20
- message: "Paste your Z.AI API key",
21
- placeholder: "sk-...",
22
- });
23
-
24
- if (options.signal?.aborted) {
25
- throw new Error("Login cancelled");
26
- }
27
-
28
- const trimmed = apiKey.trim();
29
- if (!trimmed) {
30
- throw new Error("API key is required");
31
- }
32
-
33
- options.onProgress?.("Validating API key...");
34
- await validateOpenAICompatibleApiKey({
9
+ export const loginZai = createApiKeyLogin({
10
+ providerLabel: "Z.AI",
11
+ authUrl: AUTH_URL,
12
+ instructions: "Copy your API key from the dashboard",
13
+ promptMessage: "Paste your Z.AI API key",
14
+ placeholder: "sk-...",
15
+ validation: {
16
+ kind: "chat-completions",
35
17
  provider: "Z.AI",
36
- apiKey: trimmed,
37
18
  baseUrl: API_BASE_URL,
38
19
  model: VALIDATION_MODEL,
39
- signal: options.signal,
40
- });
41
- return trimmed;
42
- }
20
+ },
21
+ });
43
22
 
44
23
  export const zaiProvider = {
45
24
  id: "zai",
@@ -1,45 +1,24 @@
1
- import { validateOpenAICompatibleApiKey } from "./api-key-validation";
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://bigmodel.cn/coding-plan/personal/overview";
6
6
  const API_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4";
7
7
  const VALIDATION_MODEL = "glm-5.1";
8
8
 
9
- export async function loginZhipuCodingPlan(options: OAuthController): Promise<string> {
10
- if (!options.onPrompt) {
11
- throw new Error("Zhipu Coding Plan login requires onPrompt callback");
12
- }
13
-
14
- options.onAuth?.({
15
- url: AUTH_URL,
16
- instructions: "Copy your API key from the Coding Plan dashboard",
17
- });
18
-
19
- const apiKey = await options.onPrompt({
20
- message: "Paste your Zhipu API key",
21
- placeholder: "<id>.<secret>",
22
- });
23
-
24
- if (options.signal?.aborted) {
25
- throw new Error("Login cancelled");
26
- }
27
-
28
- const trimmed = apiKey.trim();
29
- if (!trimmed) {
30
- throw new Error("API key is required");
31
- }
32
-
33
- options.onProgress?.("Validating API key...");
34
- await validateOpenAICompatibleApiKey({
9
+ export const loginZhipuCodingPlan = createApiKeyLogin({
10
+ providerLabel: "Zhipu Coding Plan",
11
+ authUrl: AUTH_URL,
12
+ instructions: "Copy your API key from the Coding Plan dashboard",
13
+ promptMessage: "Paste your Zhipu API key",
14
+ placeholder: "<id>.<secret>",
15
+ validation: {
16
+ kind: "chat-completions",
35
17
  provider: "Zhipu",
36
- apiKey: trimmed,
37
18
  baseUrl: API_BASE_URL,
38
19
  model: VALIDATION_MODEL,
39
- signal: options.signal,
40
- });
41
- return trimmed;
42
- }
20
+ },
21
+ });
43
22
 
44
23
  export const zhipuCodingPlanProvider = {
45
24
  id: "zhipu-coding-plan",