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

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 CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.14] - 2026-06-22
6
+
7
+ ### Added
8
+
9
+ - Added proxy support for model providers via `PI_PROXY` and `PI_PROXY_<PROVIDER>` variables
10
+ - Added `NO_PROXY` environment variable support for bypassing proxy configuration
11
+ - Added support for Sakana AI provider
12
+ - Added Sakana AI login and request base URL support for `SAKANA_*` / `FUGU_*` environment variables
13
+
14
+ ### Changed
15
+
16
+ - Consolidated API key authentication logic across registry providers
17
+ - Disabled parallel tool calls for Devin provider requests
18
+
19
+ ### Fixed
20
+
21
+ - Improved proxy bypass logic to correctly handle private IP ranges and local metadata services
22
+ - Enhanced memoization for proxy environment variable lookups to improve performance
23
+
5
24
  ## [16.1.13] - 2026-06-22
6
25
 
7
26
  ### Added
@@ -18,6 +18,7 @@
18
18
  * Resolved credentials are cached process-wide per profile and refreshed
19
19
  * 60 s before `Expiration` to absorb clock skew.
20
20
  */
21
+ import type { FetchImpl } from "../types";
21
22
  import type { AwsCredentials } from "./aws-sigv4";
22
23
  export interface ResolvedCredentials extends AwsCredentials {
23
24
  /** Absolute expiration timestamp in ms. `undefined` for non-expiring static creds. */
@@ -29,6 +30,7 @@ export interface CredentialResolveOptions {
29
30
  /** Falls back to env (`AWS_REGION` / `AWS_DEFAULT_REGION`) and finally `us-east-1`. */
30
31
  region?: string;
31
32
  signal?: AbortSignal;
33
+ fetch?: FetchImpl;
32
34
  }
33
35
  export declare function resolveAwsCredentials(opts?: CredentialResolveOptions): Promise<ResolvedCredentials>;
34
36
  /** POSIX-shell-style tokenizer used by the AWS CLI for `credential_process`.
@@ -1,5 +1,5 @@
1
- import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
2
- export declare function loginHuggingface(options: OAuthController): Promise<string>;
1
+ import type { OAuthLoginCallbacks } from "./oauth/types";
2
+ export declare const loginHuggingface: (options: import("./oauth").OAuthController) => Promise<string>;
3
3
  export declare const huggingfaceProvider: {
4
4
  readonly id: "huggingface";
5
5
  readonly name: "Hugging Face Inference";
@@ -12,17 +12,16 @@
12
12
  * International: https://api.minimax.io/v1
13
13
  * China: https://api.minimaxi.com/v1
14
14
  */
15
- import type { OAuthController } from "./types";
16
15
  /**
17
16
  * Login to MiniMax Token Plan (international).
18
17
  *
19
18
  * Opens browser to subscription page, prompts user to paste their API key.
20
19
  * Returns the API key directly (not OAuthCredentials - this isn't OAuth).
21
20
  */
22
- export declare function loginMiniMaxCode(options: OAuthController): Promise<string>;
21
+ export declare const loginMiniMaxCode: (options: import("./types").OAuthController) => Promise<string>;
23
22
  /**
24
23
  * Login to MiniMax Token Plan (China).
25
24
  *
26
25
  * Same flow as international but uses China endpoint.
27
26
  */
28
- export declare function loginMiniMaxCodeCn(options: OAuthController): Promise<string>;
27
+ export declare const loginMiniMaxCodeCn: (options: import("./types").OAuthController) => Promise<string>;
@@ -1,5 +1,5 @@
1
- import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
2
- export declare function loginQianfan(options: OAuthController): Promise<string>;
1
+ import type { OAuthLoginCallbacks } from "./oauth/types";
2
+ export declare const loginQianfan: (options: import("./oauth").OAuthController) => Promise<string>;
3
3
  export declare const qianfanProvider: {
4
4
  readonly id: "qianfan";
5
5
  readonly name: "Qianfan";
@@ -208,6 +208,10 @@ declare const ALL: ({
208
208
  readonly id: "qwen-portal";
209
209
  readonly name: "Qwen Portal";
210
210
  readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
211
+ } | {
212
+ readonly id: "sakana";
213
+ readonly name: "Sakana AI";
214
+ readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
211
215
  } | {
212
216
  readonly id: "synthetic";
213
217
  readonly name: "Synthetic";
@@ -0,0 +1,7 @@
1
+ import type { OAuthLoginCallbacks } from "./oauth/types";
2
+ export declare const loginSakana: (options: import("./oauth").OAuthController) => Promise<string>;
3
+ export declare const sakanaProvider: {
4
+ readonly id: "sakana";
5
+ readonly name: "Sakana AI";
6
+ readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
7
+ };
@@ -1,11 +1,11 @@
1
- import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
1
+ import type { OAuthLoginCallbacks } from "./oauth/types";
2
2
  /**
3
3
  * Login to Venice.
4
4
  *
5
5
  * Opens browser to API keys page, prompts user to paste their API key.
6
6
  * Returns the API key directly (not OAuthCredentials - this isn't OAuth).
7
7
  */
8
- export declare function loginVenice(options: OAuthController): Promise<string>;
8
+ export declare const loginVenice: (options: import("./oauth").OAuthController) => Promise<string>;
9
9
  export declare const veniceProvider: {
10
10
  readonly id: "venice";
11
11
  readonly name: "Venice";
@@ -1,5 +1,5 @@
1
- import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
2
- export declare function loginZai(options: OAuthController): Promise<string>;
1
+ import type { OAuthLoginCallbacks } from "./oauth/types";
2
+ export declare const loginZai: (options: import("./oauth").OAuthController) => Promise<string>;
3
3
  export declare const zaiProvider: {
4
4
  readonly id: "zai";
5
5
  readonly name: "Z.AI (GLM Coding Plan)";
@@ -1,5 +1,5 @@
1
- import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
2
- export declare function loginZhipuCodingPlan(options: OAuthController): Promise<string>;
1
+ import type { OAuthLoginCallbacks } from "./oauth/types";
2
+ export declare const loginZhipuCodingPlan: (options: import("./oauth").OAuthController) => Promise<string>;
3
3
  export declare const zhipuCodingPlanProvider: {
4
4
  readonly id: "zhipu-coding-plan";
5
5
  readonly name: "Zhipu Coding Plan (智谱)";
@@ -0,0 +1,16 @@
1
+ /**
2
+ * A UUID-shaped string: five hex groups in the canonical 8-4-4-4-12 layout.
3
+ *
4
+ * NOT a spec-compliant RFC 4122 UUID — the version/variant nibbles are left as
5
+ * raw hash output — but the shape passes everywhere a UUID string is expected.
6
+ */
7
+ export type DeterministicUuid = `${string}-${string}-${string}-${string}-${string}`;
8
+ /**
9
+ * Format the leading 128 bits of `seed`'s SHA-256 digest as a v4-shape UUID
10
+ * (8-4-4-4-12 hex groups).
11
+ *
12
+ * Deterministic: identical seeds always map to the same id, so callers get
13
+ * stable ids across requests / conversation turns (reusing message-blob ids,
14
+ * keying prompt caches) without persisting a seed→id mapping.
15
+ */
16
+ export declare function deterministicUuid(seed: string): DeterministicUuid;
@@ -0,0 +1,29 @@
1
+ import * as tls from "node:tls";
2
+ import type { FetchImpl } from "../types";
3
+ /**
4
+ * Checks if a host is local or cloud metadata, which should always bypass the proxy
5
+ * (e.g. localhost, 127/8, ::1, 169.254.169.254, metadata.google.internal).
6
+ */
7
+ export declare function isLocalOrMetadataHost(host: string): boolean;
8
+ /**
9
+ * Check if the url should bypass the proxy due to hard-coded localhost/metadata checks
10
+ * or custom NO_PROXY/no_proxy environment variables rules.
11
+ */
12
+ export declare function shouldBypassProxy(urlObj: URL): boolean;
13
+ /** Test seam: clears the provider proxy cache. */
14
+ export declare function __resetProxyCache(): void;
15
+ /**
16
+ * Normalizes provider id (e.g. github-copilot -> PI_PROXY_GITHUB_COPILOT) and looks it up.
17
+ * If not found, falls back to PI_PROXY. Results are memoized because env values are static
18
+ * for the lifetime of the process and this function is called for every outgoing request.
19
+ */
20
+ export declare function getProxyForProvider(provider: string): string | undefined;
21
+ /**
22
+ * Wraps a fetch implementation to inject proxy options for non-local hosts.
23
+ */
24
+ export declare function wrapFetchForProxy(fetchImpl: FetchImpl, provider: string): FetchImpl;
25
+ /**
26
+ * Tunnel a socket connection through an HTTP CONNECT proxy.
27
+ * This is used specifically to wrap Node's `http2.connect(baseUrl, { createConnection })` for Cursor.
28
+ */
29
+ export declare function connectProxiedSocket(proxyUrlStr: string, targetUrlStr: string): Promise<tls.TLSSocket>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.1.13",
4
+ "version": "16.1.14",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "@oh-my-pi/pi-catalog": "16.1.13",
42
- "@oh-my-pi/pi-utils": "16.1.13",
43
- "@oh-my-pi/pi-wire": "16.1.13",
41
+ "@oh-my-pi/pi-catalog": "16.1.14",
42
+ "@oh-my-pi/pi-utils": "16.1.14",
43
+ "@oh-my-pi/pi-wire": "16.1.14",
44
44
  "arktype": "^2.2.0",
45
45
  "zod": "^4"
46
46
  },
@@ -29,6 +29,7 @@ import * as piNative from "../providers/pi-native-server";
29
29
  import { isUsageLimitError } from "../rate-limit-utils";
30
30
  import { streamSimple } from "../stream";
31
31
  import type { Api, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "../types";
32
+ import { deterministicUuid } from "../utils/deterministic-id";
32
33
  import { parseBind } from "../utils/parse-bind";
33
34
  import { captureRequestHeaders, corsHeaders, isAuthorized, json, resolvePeer, withCors } from "./http";
34
35
  import type {
@@ -110,11 +111,9 @@ function deriveSessionId(modelId: string, context: Context): string {
110
111
  parts.push(JSON.stringify({ role: first.role, content: first.content }));
111
112
  }
112
113
  const seed = parts.join("\u0000");
113
- const hex = new Bun.CryptoHasher("sha256").update(seed).digest("hex");
114
- // Format the leading 128 bits as a v4-shape UUID (8-4-4-4-12). Codex's
115
- // `normalizeOpenAIResponsesPromptCacheKey` accepts ≤64 chars verbatim, so
116
- // the 36-char UUID flows through unchanged.
117
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
114
+ // The 36-char UUID flows through unchanged: Codex's
115
+ // `normalizeOpenAIResponsesPromptCacheKey` accepts ≤64 chars verbatim.
116
+ return deterministicUuid(seed);
118
117
  }
119
118
 
120
119
  function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: AbortSignal): SimpleStreamOptions {
@@ -302,6 +302,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
302
302
  profile: options.profile,
303
303
  region,
304
304
  signal: options.signal,
305
+ fetch: options.fetch,
305
306
  });
306
307
  }
307
308
  const signed = await signRequest({
@@ -23,6 +23,7 @@ import * as fs from "node:fs";
23
23
  import * as os from "node:os";
24
24
  import * as path from "node:path";
25
25
  import { $env, isEnoent, logger } from "@oh-my-pi/pi-utils";
26
+ import type { FetchImpl } from "../types";
26
27
  import { raceWithSignal } from "../utils/abort";
27
28
  import type { AwsCredentials } from "./aws-sigv4";
28
29
 
@@ -37,6 +38,7 @@ export interface CredentialResolveOptions {
37
38
  /** Falls back to env (`AWS_REGION` / `AWS_DEFAULT_REGION`) and finally `us-east-1`. */
38
39
  region?: string;
39
40
  signal?: AbortSignal;
41
+ fetch?: FetchImpl;
40
42
  }
41
43
 
42
44
  const REFRESH_SKEW_MS = 60_000;
@@ -75,9 +77,10 @@ export async function resolveAwsCredentials(opts: CredentialResolveOptions = {})
75
77
  const existing = inflight.get(cacheKey);
76
78
  if (existing) return raceWithSignal(existing, opts.signal);
77
79
 
80
+ const fetchImpl = opts.fetch ?? (globalThis.fetch as FetchImpl);
78
81
  const promise = (async () => {
79
82
  try {
80
- const creds = await resolveFresh(profile, region, AbortSignal.timeout(SHARED_RESOLVE_TIMEOUT_MS));
83
+ const creds = await resolveFresh(profile, region, AbortSignal.timeout(SHARED_RESOLVE_TIMEOUT_MS), fetchImpl);
81
84
  cache.set(cacheKey, { creds, expiresAt: creds.expiresAt ?? Number.POSITIVE_INFINITY });
82
85
  return creds;
83
86
  } finally {
@@ -88,18 +91,23 @@ export async function resolveAwsCredentials(opts: CredentialResolveOptions = {})
88
91
  return raceWithSignal(promise, opts.signal);
89
92
  }
90
93
 
91
- async function resolveFresh(profile: string, region: string, signal?: AbortSignal): Promise<ResolvedCredentials> {
94
+ async function resolveFresh(
95
+ profile: string,
96
+ region: string,
97
+ signal?: AbortSignal,
98
+ fetchImpl: FetchImpl = globalThis.fetch as FetchImpl,
99
+ ): Promise<ResolvedCredentials> {
92
100
  // 1. Environment first — matches the AWS SDK chain order.
93
101
  const envCreds = readEnvCredentials();
94
102
  if (envCreds) return envCreds;
95
103
 
96
104
  // 2. Profile (static or SSO).
97
- const profileCreds = await readProfileCredentials(profile, region, signal);
105
+ const profileCreds = await readProfileCredentials(profile, region, signal, fetchImpl);
98
106
  if (profileCreds) return profileCreds;
99
107
 
100
108
  // 3. EC2 IMDSv2.
101
109
  if ($env.AWS_EC2_METADATA_DISABLED?.toLowerCase() !== "true") {
102
- const imdsCreds = await readImdsCredentials(signal);
110
+ const imdsCreds = await readImdsCredentials(signal, fetchImpl);
103
111
  if (imdsCreds) return imdsCreds;
104
112
  }
105
113
 
@@ -167,6 +175,7 @@ async function readProfileCredentials(
167
175
  profile: string,
168
176
  region: string,
169
177
  signal: AbortSignal | undefined,
178
+ fetchImpl: FetchImpl,
170
179
  ): Promise<ResolvedCredentials | undefined> {
171
180
  const home = os.homedir();
172
181
  const credentialsPath = $env.AWS_SHARED_CREDENTIALS_FILE || path.join(home, ".aws", "credentials");
@@ -195,7 +204,7 @@ async function readProfileCredentials(
195
204
  }
196
205
 
197
206
  if (merged.sso_account_id && merged.sso_role_name) {
198
- return readSsoCredentials(merged, configIni, region, signal);
207
+ return readSsoCredentials(merged, configIni, region, signal, fetchImpl);
199
208
  }
200
209
 
201
210
  if (merged.credential_process) {
@@ -217,6 +226,7 @@ async function readSsoCredentials(
217
226
  configIni: IniFile | undefined,
218
227
  defaultRegion: string,
219
228
  signal: AbortSignal | undefined,
229
+ fetchImpl: FetchImpl,
220
230
  ): Promise<ResolvedCredentials | undefined> {
221
231
  // Two SSO profile shapes:
222
232
  // - legacy: `sso_start_url` + `sso_region` directly on the profile
@@ -246,7 +256,7 @@ async function readSsoCredentials(
246
256
  `https://portal.sso.${ssoRegion}.amazonaws.com/federation/credentials` +
247
257
  `?account_id=${encodeURIComponent(profileCfg.sso_account_id)}` +
248
258
  `&role_name=${encodeURIComponent(profileCfg.sso_role_name)}`;
249
- const response = await fetch(url, {
259
+ const response = await fetchImpl(url, {
250
260
  method: "GET",
251
261
  headers: { "x-amz-sso_bearer_token": token.accessToken },
252
262
  signal,
@@ -480,11 +490,14 @@ export function tokenizeCredentialProcessCommand(cmd: string): string[] {
480
490
  const IMDS_HOST = "169.254.169.254";
481
491
  const IMDS_TIMEOUT_MS = 1000;
482
492
 
483
- async function readImdsCredentials(parentSignal: AbortSignal | undefined): Promise<ResolvedCredentials | undefined> {
493
+ async function readImdsCredentials(
494
+ parentSignal: AbortSignal | undefined,
495
+ fetchImpl: FetchImpl,
496
+ ): Promise<ResolvedCredentials | undefined> {
484
497
  const timeout = AbortSignal.timeout(IMDS_TIMEOUT_MS);
485
498
  const signal = parentSignal ? AbortSignal.any([parentSignal, timeout]) : timeout;
486
499
  try {
487
- const tokenRes = await fetch(`http://${IMDS_HOST}/latest/api/token`, {
500
+ const tokenRes = await fetchImpl(`http://${IMDS_HOST}/latest/api/token`, {
488
501
  method: "PUT",
489
502
  headers: { "x-aws-ec2-metadata-token-ttl-seconds": "21600" },
490
503
  signal,
@@ -492,7 +505,7 @@ async function readImdsCredentials(parentSignal: AbortSignal | undefined): Promi
492
505
  if (!tokenRes.ok) return undefined;
493
506
  const token = await tokenRes.text();
494
507
 
495
- const roleRes = await fetch(`http://${IMDS_HOST}/latest/meta-data/iam/security-credentials/`, {
508
+ const roleRes = await fetchImpl(`http://${IMDS_HOST}/latest/meta-data/iam/security-credentials/`, {
496
509
  headers: { "x-aws-ec2-metadata-token": token },
497
510
  signal,
498
511
  });
@@ -500,7 +513,7 @@ async function readImdsCredentials(parentSignal: AbortSignal | undefined): Promi
500
513
  const role = (await roleRes.text()).trim();
501
514
  if (!role) return undefined;
502
515
 
503
- const credsRes = await fetch(
516
+ const credsRes = await fetchImpl(
504
517
  `http://${IMDS_HOST}/latest/meta-data/iam/security-credentials/${encodeURIComponent(role)}`,
505
518
  {
506
519
  headers: { "x-aws-ec2-metadata-token": token },
@@ -124,8 +124,10 @@ import type {
124
124
  ToolResultMessage,
125
125
  } from "../types";
126
126
  import { normalizeSystemPrompts } from "../utils";
127
+ import { deterministicUuid } from "../utils/deterministic-id";
127
128
  import { AssistantMessageEventStream } from "../utils/event-stream";
128
129
  import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse";
130
+ import { connectProxiedSocket, getProxyForProvider, shouldBypassProxy } from "../utils/proxy";
129
131
  import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug";
130
132
  import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
131
133
  import { toolWireSchema } from "../utils/schema/wire";
@@ -376,7 +378,15 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
376
378
  })
377
379
  : undefined;
378
380
 
379
- h2Client = http2.connect(baseUrl);
381
+ const proxyUrl = shouldBypassProxy(new URL(baseUrl)) ? undefined : getProxyForProvider(model.provider);
382
+ if (proxyUrl) {
383
+ const tlsSocket = await connectProxiedSocket(proxyUrl, baseUrl);
384
+ h2Client = http2.connect(baseUrl, {
385
+ createConnection: () => tlsSocket,
386
+ });
387
+ } else {
388
+ h2Client = http2.connect(baseUrl);
389
+ }
380
390
 
381
391
  h2Request = h2Client.request(requestHeaders);
382
392
 
@@ -2258,19 +2268,6 @@ function extractAssistantMessageText(msg: Message): string {
2258
2268
  .join("\n");
2259
2269
  }
2260
2270
 
2261
- /**
2262
- * Derive a stable, UUID-formatted `message_id` from a content key.
2263
- * Ensures identical historical messages hash to the same blob IDs across
2264
- * requests, so `conversationBlobStores` does not grow unboundedly and
2265
- * unchanged history reuses existing blob IDs.
2266
- */
2267
- type CursorMessageId = `${string}-${string}-${string}-${string}-${string}`;
2268
-
2269
- function deterministicMessageId(key: string): CursorMessageId {
2270
- const hex = createHash("sha256").update(key).digest("hex");
2271
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
2272
- }
2273
-
2274
2271
  /**
2275
2272
  * Index of the last user/developer message in `messages`, or -1 if none.
2276
2273
  * Used to exclude the current user turn from history builders — it goes in
@@ -2394,7 +2391,7 @@ function buildConversationTurns(
2394
2391
  const userMessage = createCursorUserMessage(
2395
2392
  msg.content,
2396
2393
  userText,
2397
- deterministicMessageId(`u:${turns.length}:${cursorUserContentKey(msg.content)}`),
2394
+ deterministicUuid(`u:${turns.length}:${cursorUserContentKey(msg.content)}`),
2398
2395
  );
2399
2396
  const userMessageBytes = toBinary(UserMessageSchema, userMessage);
2400
2397
  const userMessageBlobId = storeCursorBlob(blobStore, userMessageBytes);
@@ -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({
@@ -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",