@oh-my-pi/pi-ai 17.2.5 → 17.2.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 (56) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/types/auth-broker/client.d.ts +7 -0
  3. package/dist/types/auth-broker/server.d.ts +11 -0
  4. package/dist/types/auth-broker/wire-schema-resource.d.ts +1 -1
  5. package/dist/types/auth-broker/wire-schemas.d.ts +4 -2
  6. package/dist/types/dialect/examples.d.ts +8 -0
  7. package/dist/types/error/aws.d.ts +5 -1
  8. package/dist/types/index.d.ts +1 -1
  9. package/dist/types/providers/anthropic-messages-server-schema.d.ts +695 -255
  10. package/dist/types/providers/aws-credentials.d.ts +5 -10
  11. package/dist/types/providers/bedrock-mantle.d.ts +13 -0
  12. package/dist/types/providers/openai-chat-server-schema.d.ts +909 -293
  13. package/dist/types/providers/openai-responses-server-schema.d.ts +980 -353
  14. package/dist/types/registry/amazon-bedrock.d.ts +7 -1
  15. package/dist/types/registry/aws.d.ts +13 -0
  16. package/dist/types/registry/bedrock-mantle.d.ts +22 -0
  17. package/dist/types/registry/registry.d.ts +25 -1
  18. package/dist/types/registry/types.d.ts +24 -0
  19. package/dist/types/types.d.ts +13 -5
  20. package/dist/types/usage.d.ts +152 -46
  21. package/dist/types/utils/aws-profile.d.ts +17 -0
  22. package/dist/types/utils/schema/wire.d.ts +1 -1
  23. package/dist/types/utils/validation.d.ts +24 -0
  24. package/package.json +5 -5
  25. package/src/auth-broker/client.ts +2 -1
  26. package/src/auth-broker/server.ts +2 -1
  27. package/src/auth-broker/wire-schema-resource.ts +1 -1
  28. package/src/dialect/examples.ts +34 -8
  29. package/src/dialect/inventory.ts +2 -2
  30. package/src/error/aws.ts +5 -1
  31. package/src/index.ts +1 -1
  32. package/src/providers/amazon-bedrock.ts +5 -5
  33. package/src/providers/anthropic-messages-server-schema.ts +1 -1
  34. package/src/providers/anthropic-messages-server.ts +1 -1
  35. package/src/providers/aws-credentials.ts +262 -76
  36. package/src/providers/bedrock-mantle.ts +110 -0
  37. package/src/providers/cursor.ts +31 -3
  38. package/src/providers/google-gemini-cli.ts +1 -1
  39. package/src/providers/ollama.ts +27 -1
  40. package/src/providers/openai-chat-server-schema.ts +1 -1
  41. package/src/providers/openai-chat-server.ts +1 -1
  42. package/src/providers/openai-codex-responses.ts +1 -1
  43. package/src/providers/openai-responses-server-schema.ts +1 -1
  44. package/src/providers/openai-responses-server.ts +1 -1
  45. package/src/registry/amazon-bedrock.ts +9 -14
  46. package/src/registry/aws.ts +57 -0
  47. package/src/registry/bedrock-mantle.ts +34 -0
  48. package/src/registry/google-vertex.ts +2 -2
  49. package/src/registry/registry.ts +2 -0
  50. package/src/registry/types.ts +30 -0
  51. package/src/stream.ts +46 -30
  52. package/src/types.ts +14 -6
  53. package/src/usage.ts +1 -1
  54. package/src/utils/aws-profile.ts +88 -0
  55. package/src/utils/schema/wire.ts +1 -1
  56. package/src/utils/validation.ts +2 -1
@@ -144,6 +144,7 @@ import { isKimiK3ModelId } from "@oh-my-pi/pi-catalog/identity";
144
144
  import { calculateCost } from "@oh-my-pi/pi-catalog/models";
145
145
  import {
146
146
  $env,
147
+ logger,
147
148
  parseJsonWithRepair,
148
149
  parseStreamingJson,
149
150
  parseStreamingJsonThrottled,
@@ -233,6 +234,7 @@ const NOT_IMPLEMENTED = `Not implemented by this client`;
233
234
 
234
235
  const conversationStateCache = new Map<string, ConversationStateStructure>();
235
236
  const conversationBlobStores = new Map<string, Map<string, Uint8Array>>();
237
+ const warnedCursorKimiK3ReplayMessages = new Set<string>();
236
238
 
237
239
  export interface CursorOptions extends StreamOptions {
238
240
  customSystemPrompt?: string;
@@ -3902,6 +3904,15 @@ export function processInteractionUpdate(
3902
3904
  }
3903
3905
  } else if (updateCase === "turnEnded") {
3904
3906
  output.stopReason = "stop";
3907
+ if (
3908
+ isKimiK3ModelId(output.model) &&
3909
+ !output.content.some(item => item.type === "thinking" && item.thinking.length > 0)
3910
+ ) {
3911
+ logger.warn(
3912
+ "Cursor kimi-k3 turn completed without thinking blocks; persisted history will replay this turn without reasoning",
3913
+ { model: output.model, messageTimestamp: output.timestamp },
3914
+ );
3915
+ }
3905
3916
  } else if (updateCase === "tokenDelta") {
3906
3917
  const tokenDelta = update.message.value;
3907
3918
  usageState.sawTokenDelta = true;
@@ -4113,17 +4124,34 @@ function assertCursorKimiK3HistoryReplayable(
4113
4124
  ): void {
4114
4125
  if (!targetModelId || !isKimiK3ModelId(targetModelId)) return;
4115
4126
  const historyEnd = activeUserMessageIndex >= 0 ? activeUserMessageIndex : messages.length;
4127
+ const missingThinkingTurns: number[] = [];
4128
+ const newlyWarnedKeys: string[] = [];
4129
+ let assistantTurn = 0;
4116
4130
  for (let i = 0; i < historyEnd; i++) {
4117
4131
  const msg = messages[i];
4118
4132
  if (msg.role !== "assistant") continue;
4133
+ assistantTurn++;
4119
4134
  const isSameCursorModel = msg.api === "cursor-agent" && msg.provider === "cursor" && msg.model === targetModelId;
4120
- const hasThinking = msg.content.some(item => item.type === "thinking" && item.thinking.length > 0);
4121
- if (!isSameCursorModel || !hasThinking) {
4135
+ if (!isSameCursorModel) {
4136
+ // Foreign history genuinely cannot replay K3 thinking: another model's
4137
+ // turns carry no K3-signed reasoning to reconstruct.
4122
4138
  throw new AIError.ValidationError(
4123
- `Cursor ${targetModelId} requires complete same-model thinking history; start a new session instead of continuing history from ${msg.provider}/${msg.model}.`,
4139
+ `Cursor ${targetModelId} cannot continue history from a different model (${msg.provider}/${msg.model}); start a new session.`,
4124
4140
  );
4125
4141
  }
4142
+ const hasThinking = msg.content.some(item => item.type === "thinking" && item.thinking.length > 0);
4143
+ if (hasThinking) continue;
4144
+ const warningKey = `${msg.api}\0${msg.provider}\0${msg.model}\0${msg.timestamp}`;
4145
+ if (warnedCursorKimiK3ReplayMessages.has(warningKey)) continue;
4146
+ missingThinkingTurns.push(assistantTurn);
4147
+ newlyWarnedKeys.push(warningKey);
4126
4148
  }
4149
+ if (missingThinkingTurns.length === 0) return;
4150
+ for (const key of newlyWarnedKeys) warnedCursorKimiK3ReplayMessages.add(key);
4151
+ logger.warn(
4152
+ `Cursor kimi-k3 history contains same-model assistant turn(s) ${missingThinkingTurns.join(", ")} without thinking blocks; replaying those spans without reasoning may make generation less stable`,
4153
+ { model: targetModelId, assistantTurns: missingThinkingTurns },
4154
+ );
4127
4155
  }
4128
4156
 
4129
4157
  /**
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { createHash, randomBytes, randomUUID } from "node:crypto";
7
7
  import { scheduler } from "node:timers/promises";
8
+ import { type } from "@oh-my-pi/omptype";
8
9
  import { calculateCost } from "@oh-my-pi/pi-catalog/models";
9
10
  import {
10
11
  ANTIGRAVITY_SYSTEM_INSTRUCTION,
@@ -13,7 +14,6 @@ import {
13
14
  getGeminiCliHeaders,
14
15
  } from "@oh-my-pi/pi-catalog/wire/gemini-headers";
15
16
  import { extractHttpStatusFromError, fetchWithRetry, readSseJson } from "@oh-my-pi/pi-utils";
16
- import { type } from "arktype";
17
17
  import * as AIError from "../error";
18
18
  import type {
19
19
  Api,
@@ -251,7 +251,7 @@ function convertMessages(model: Model<"ollama-chat">, context: Context): OllamaM
251
251
  const messages: Message[] = [...systemMessages, ...context.messages];
252
252
  const isCloud = model.provider === "ollama-cloud";
253
253
  const supportsImages = model.input.includes("image");
254
- return transformMessages(messages, model).map((msg, index) => {
254
+ const converted = transformMessages(messages, model).map((msg, index) => {
255
255
  // Real `systemPrompt` entries (always emitted first) stay on Ollama's
256
256
  // `system` role. After the static prefix, a developer turn keeps `system`
257
257
  // when it's an agent-owned control instruction (empty/unexpected-stop
@@ -272,6 +272,21 @@ function convertMessages(model: Model<"ollama-chat">, context: Context): OllamaM
272
272
  }
273
273
  return converted;
274
274
  });
275
+ // Ollama returns `done_reason: "load"` and generates nothing when a request
276
+ // carries no `user`-role message (e.g. a plan-approval handoff into a fresh
277
+ // session whose only non-system turn is an agent-attributed developer message
278
+ // mapped to `system`). Demote the last non-prefix system turn to `user` so the
279
+ // request can actually produce output; the static system-prompt prefix stays
280
+ // on `system` for prefix caching. (#7465)
281
+ if (!converted.some(m => m.role === "user")) {
282
+ for (let i = converted.length - 1; i >= systemPrompts.length; i--) {
283
+ if (converted[i].role === "system") {
284
+ converted[i].role = "user";
285
+ break;
286
+ }
287
+ }
288
+ }
289
+ return converted;
275
290
  }
276
291
 
277
292
  function convertTools(tools: Tool[] | undefined): OllamaFunctionTool[] | undefined {
@@ -403,6 +418,12 @@ function mapDoneReason(doneReason: string | undefined, output: AssistantMessage)
403
418
  if (doneReason === "tool_calls") {
404
419
  return "toolUse";
405
420
  }
421
+ if (doneReason === "load") {
422
+ // Ollama emits done_reason:"load" (model loaded, nothing generated) when a
423
+ // request has no user-role turn. Surface it as an error rather than a clean
424
+ // empty stop so it isn't laundered and retried behind a misleading hint. (#7465)
425
+ return "error";
426
+ }
406
427
  if (doneReason === undefined && output.content.some(block => block.type === "toolCall")) {
407
428
  return "toolUse";
408
429
  }
@@ -411,6 +432,8 @@ function mapDoneReason(doneReason: string | undefined, output: AssistantMessage)
411
432
 
412
433
  const EMPTY_OLLAMA_LENGTH_COMPLETION_MESSAGE =
413
434
  "Model returned no content: prompt filled the context window; raise Ollama num_ctx or shorten the prompt.";
435
+ const EMPTY_OLLAMA_LOAD_COMPLETION_MESSAGE =
436
+ "Ollama loaded the model but generated nothing (done_reason: load): the request contained no user-role message.";
414
437
 
415
438
  function hasVisibleAssistantContent(output: AssistantMessage): boolean {
416
439
  return output.content.some(block => {
@@ -697,6 +720,9 @@ const streamOllamaOnce = (
697
720
  output.stopReason = "error";
698
721
  output.errorMessage = EMPTY_OLLAMA_LENGTH_COMPLETION_MESSAGE;
699
722
  }
723
+ if (output.stopReason === "error" && !output.errorMessage) {
724
+ output.errorMessage = EMPTY_OLLAMA_LOAD_COMPLETION_MESSAGE;
725
+ }
700
726
  // Tool calls always mean "execute and continue" in the OpenAI/Ollama contract.
701
727
  // If the turn produced tool-call blocks but reported a natural `stop`, promote
702
728
  // to `toolUse` so the agent loop runs them (it gates execution on the stop
@@ -7,7 +7,7 @@
7
7
  * `stream_options.include_obfuscation`) — does not trip 400s on shapes we simply ignore.
8
8
  */
9
9
 
10
- import { type } from "arktype";
10
+ import { type } from "@oh-my-pi/omptype";
11
11
  import type {
12
12
  ChatCompletionContentPart,
13
13
  ChatCompletionCreateParams,
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { type } from "arktype";
2
+ import { type } from "@oh-my-pi/omptype";
3
3
  import { resolvePromptCacheKey } from "../auth-gateway/http";
4
4
  /**
5
5
  * Parsed inbound OpenAI chat-completions request, ready to feed into pi-ai
@@ -1,5 +1,6 @@
1
1
  import * as os from "node:os";
2
2
  import { scheduler } from "node:timers/promises";
3
+ import { type } from "@oh-my-pi/omptype";
3
4
  import { calculateCost } from "@oh-my-pi/pi-catalog/models";
4
5
  import {
5
6
  CODEX_BASE_URL,
@@ -19,7 +20,6 @@ import {
19
20
  readSseJson,
20
21
  structuredCloneJSON,
21
22
  } from "@oh-my-pi/pi-utils";
22
- import { type } from "arktype";
23
23
  import packageJson from "../../package.json" with { type: "json" };
24
24
  import * as AIError from "../error";
25
25
  import { getEnvApiKey, isOfficialCodexApiUrl } from "../stream";
@@ -8,7 +8,7 @@
8
8
  * is a worse outcome than dropping them on the floor.
9
9
  */
10
10
 
11
- import { type } from "arktype";
11
+ import { type } from "@oh-my-pi/omptype";
12
12
  import type {
13
13
  EasyInputMessage,
14
14
  ResponseCreateParams,
@@ -9,8 +9,8 @@
9
9
  * Inverse direction (source-of-truth for item shapes): ../../providers/openai-responses.ts
10
10
  */
11
11
 
12
+ import { type } from "@oh-my-pi/omptype";
12
13
  import { logger, structuredCloneJSON } from "@oh-my-pi/pi-utils";
13
- import { type } from "arktype";
14
14
  import { resolvePromptCacheKey } from "../auth-gateway/http";
15
15
  import type { AuthGatewayStreamControl, AuthGatewayParsedRequest as ParsedRequest } from "../auth-gateway/types";
16
16
  import * as AIError from "../error";
@@ -1,22 +1,17 @@
1
- import { $env } from "@oh-my-pi/pi-utils";
1
+ import { type AwsBedrockProviderOptions, resolveAwsRegistryApiKey } from "./aws";
2
2
  import type { ProviderDefinition } from "./types";
3
3
 
4
4
  export const amazonBedrockProvider = {
5
5
  id: "amazon-bedrock",
6
6
  name: "Amazon Bedrock",
7
7
  // Amazon Bedrock accepts bearer tokens, IAM keys, profiles, ECS/IRSA credential chains.
8
- envKeys: () => {
9
- const hasEcsCredentials =
10
- !!$env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || !!$env.AWS_CONTAINER_CREDENTIALS_FULL_URI;
11
- const hasWebIdentity = !!$env.AWS_WEB_IDENTITY_TOKEN_FILE && !!$env.AWS_ROLE_ARN;
12
- if (
13
- $env.AWS_PROFILE ||
14
- ($env.AWS_ACCESS_KEY_ID && $env.AWS_SECRET_ACCESS_KEY) ||
15
- $env.AWS_BEARER_TOKEN_BEDROCK ||
16
- hasEcsCredentials ||
17
- hasWebIdentity
18
- ) {
19
- return "<authenticated>";
20
- }
8
+ envKeys: resolveAwsRegistryApiKey,
9
+ mapSimpleOptions: options => {
10
+ const awsOptions = options.providerOptions as AwsBedrockProviderOptions | undefined;
11
+ return {
12
+ region: awsOptions?.region,
13
+ profile: awsOptions?.profile,
14
+ bearerToken: awsOptions?.bearerToken,
15
+ };
21
16
  },
22
17
  } as const satisfies ProviderDefinition;
@@ -0,0 +1,57 @@
1
+ import * as fs from "node:fs";
2
+ import { $env } from "@oh-my-pi/pi-utils";
3
+ import { hasConfiguredAwsProfile } from "../utils/aws-profile";
4
+ import { AUTHENTICATED_SENTINEL } from "./types";
5
+
6
+ export interface AwsBedrockProviderOptions extends Readonly<Record<string, unknown>> {
7
+ /** AWS region used in the service endpoint and SigV4 credential scope. */
8
+ region?: string;
9
+ /** Named AWS shared-credentials/config profile. */
10
+ profile?: string;
11
+ /** Amazon Bedrock API key sent as a bearer token, ahead of SigV4 credential resolution. */
12
+ bearerToken?: string;
13
+ }
14
+
15
+ function isEc2Host(): boolean {
16
+ for (const candidate of [
17
+ "/sys/hypervisor/uuid",
18
+ "/sys/devices/virtual/dmi/id/product_uuid",
19
+ "/sys/devices/virtual/dmi/id/board_asset_tag",
20
+ ]) {
21
+ try {
22
+ const value = fs.readFileSync(candidate, "utf8").trim().toLowerCase();
23
+ if (value.startsWith("ec2")) return true;
24
+ } catch {
25
+ // Missing/unreadable DMI metadata means this probe is inconclusive.
26
+ }
27
+ }
28
+ return false;
29
+ }
30
+
31
+ export function hasAwsCredentialSource(): boolean {
32
+ const hasEcsCredentials = !!$env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || !!$env.AWS_CONTAINER_CREDENTIALS_FULL_URI;
33
+ const hasWebIdentity = !!$env.AWS_WEB_IDENTITY_TOKEN_FILE && !!$env.AWS_ROLE_ARN;
34
+ const hasProfile = hasConfiguredAwsProfile();
35
+ const hasInstanceRole =
36
+ $env.AWS_EC2_METADATA_DISABLED?.toLowerCase() !== "true" &&
37
+ (!!$env.AWS_EC2_METADATA_SERVICE_ENDPOINT || isEc2Host());
38
+ return !!(
39
+ ($env.AWS_ACCESS_KEY_ID && $env.AWS_SECRET_ACCESS_KEY) ||
40
+ $env.AWS_BEARER_TOKEN_BEDROCK ||
41
+ hasWebIdentity ||
42
+ hasProfile ||
43
+ hasEcsCredentials ||
44
+ hasInstanceRole
45
+ );
46
+ }
47
+
48
+ /** Registry key marker for AWS transports that resolve their own bearer/IAM credentials. */
49
+ export function resolveAwsRegistryApiKey(): string | undefined {
50
+ return hasAwsCredentialSource() ? AUTHENTICATED_SENTINEL : undefined;
51
+ }
52
+
53
+ /** Resolve a real AWS bearer token while filtering the registry's auth marker. */
54
+ export function resolveAwsBearerToken(apiKey?: string, bearerToken?: string): string | undefined {
55
+ const resolvedApiKey = apiKey === AUTHENTICATED_SENTINEL ? undefined : apiKey;
56
+ return bearerToken || resolvedApiKey || $env.AWS_BEARER_TOKEN_BEDROCK;
57
+ }
@@ -0,0 +1,34 @@
1
+ import {
2
+ type BedrockMantleOptions,
3
+ createBedrockMantleAuthenticatedFetch,
4
+ prepareBedrockMantleRequest,
5
+ } from "../providers/bedrock-mantle";
6
+ import type { Model } from "../types";
7
+ import { resolveAwsRegion } from "../utils/aws-profile";
8
+ import { resolveAwsBearerToken, resolveAwsRegistryApiKey } from "./aws";
9
+ import type { ProviderDefinition } from "./types";
10
+
11
+ export const bedrockMantleProvider = {
12
+ id: "bedrock-mantle",
13
+ name: "Amazon Bedrock Mantle",
14
+ envKeys: resolveAwsRegistryApiKey,
15
+ allowsMissingApiKey: true,
16
+ prepareRequest: (model, options) =>
17
+ prepareBedrockMantleRequest(model as Model<"openai-responses">, options as BedrockMantleOptions),
18
+ mapSimpleOptions: options => ({ providerOptions: options.providerOptions }),
19
+ prepareModelDiscovery: config => {
20
+ const bearerToken = resolveAwsBearerToken(config.apiKey);
21
+ if (!bearerToken) {
22
+ return { ...config, apiKey: undefined, authenticated: false };
23
+ }
24
+ const region = resolveAwsRegion();
25
+ return {
26
+ authenticated: true,
27
+ baseUrl: `https://bedrock-mantle.${encodeURIComponent(region)}.api.aws/openai/v1`,
28
+ fetch: createBedrockMantleAuthenticatedFetch({
29
+ fetch: config.fetch,
30
+ providerOptions: { bearerToken, region },
31
+ }),
32
+ };
33
+ },
34
+ } as const satisfies ProviderDefinition;
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { $env } from "@oh-my-pi/pi-utils";
5
- import type { ProviderDefinition } from "./types";
5
+ import { AUTHENTICATED_SENTINEL, type ProviderDefinition } from "./types";
6
6
 
7
7
  let cachedVertexAdcCredentialsExists: boolean | null = null;
8
8
 
@@ -32,7 +32,7 @@ export const googleVertexProvider = {
32
32
  const hasProject = !!($env.GOOGLE_CLOUD_PROJECT || $env.GCP_PROJECT || $env.GCLOUD_PROJECT);
33
33
  const hasLocation = !!($env.GOOGLE_VERTEX_LOCATION || $env.GOOGLE_CLOUD_LOCATION || $env.VERTEX_LOCATION);
34
34
  if (hasCredentials && hasProject && hasLocation) {
35
- return "<authenticated>";
35
+ return AUTHENTICATED_SENTINEL;
36
36
  }
37
37
  },
38
38
  } as const satisfies ProviderDefinition;
@@ -7,6 +7,7 @@ import { amazonBedrockProvider } from "./amazon-bedrock";
7
7
  import { anthropicProvider } from "./anthropic";
8
8
  import { azureProvider } from "./azure";
9
9
  import { basetenProvider } from "./baseten";
10
+ import { bedrockMantleProvider } from "./bedrock-mantle";
10
11
  import { cerebrasProvider } from "./cerebras";
11
12
  import { cloudflareAiGatewayProvider } from "./cloudflare-ai-gateway";
12
13
  import { coreWeaveProvider } from "./coreweave";
@@ -157,6 +158,7 @@ const ALL = [
157
158
  mistralProvider,
158
159
  minimaxProvider,
159
160
  amazonBedrockProvider,
161
+ bedrockMantleProvider,
160
162
  gmiCloudProvider,
161
163
  ];
162
164
 
@@ -9,6 +9,8 @@
9
9
  * (default model, model-manager factory, catalog discovery) lives in
10
10
  * `@oh-my-pi/pi-catalog`'s descriptor table.
11
11
  */
12
+
13
+ import type { Api, FetchImpl, Model, SimpleStreamOptions, StreamOptions } from "../types";
12
14
  import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types";
13
15
 
14
16
  /**
@@ -18,6 +20,26 @@ import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types";
18
20
  */
19
21
  export type KeyResolver = string | (() => string | undefined);
20
22
 
23
+ /** Credentials are resolved by the provider transport rather than used as a bearer string. */
24
+ export const AUTHENTICATED_SENTINEL = "<authenticated>";
25
+
26
+ export interface PreparedProviderRequest {
27
+ readonly model: Model<Api>;
28
+ readonly options: StreamOptions;
29
+ }
30
+
31
+ export type ProviderRequestPreparer = (model: Model<Api>, options: StreamOptions) => PreparedProviderRequest;
32
+ export type ProviderSimpleOptionsMapper = (options: SimpleStreamOptions) => Readonly<Record<string, unknown>>;
33
+
34
+ export interface ProviderModelDiscoveryConfig {
35
+ readonly apiKey?: string;
36
+ readonly baseUrl?: string;
37
+ readonly fetch?: FetchImpl;
38
+ readonly authenticated?: boolean;
39
+ }
40
+
41
+ export type ProviderModelDiscoveryPreparer = (config: ProviderModelDiscoveryConfig) => ProviderModelDiscoveryConfig;
42
+
21
43
  /**
22
44
  * Declarative description of a single provider's auth/login wiring. All
23
45
  * fields are optional except `id`/`name`; presence of a field opts the
@@ -42,6 +64,14 @@ export interface ProviderDefinition {
42
64
  readonly showInLoginList?: boolean;
43
65
  // --- env-var fallback (the catalog table's `envVars` supplies plain names; set this only for computed resolvers) ---
44
66
  readonly envKeys?: KeyResolver;
67
+ /** Provider transport can authenticate without a resolved API-key string. */
68
+ readonly allowsMissingApiKey?: boolean;
69
+ /** Provider-owned request shaping applied before generic API dispatch. */
70
+ readonly prepareRequest?: ProviderRequestPreparer;
71
+ /** Provider-owned projection from the generic simple-stream option bag. */
72
+ readonly mapSimpleOptions?: ProviderSimpleOptionsMapper;
73
+ /** Provider-owned authentication and endpoint setup for model discovery. */
74
+ readonly prepareModelDiscovery?: ProviderModelDiscoveryPreparer;
45
75
  // --- interactive login (OAuthProviderInterface-compatible) ---
46
76
  readonly login?: (callbacks: OAuthLoginCallbacks) => Promise<OAuthCredentials | string>;
47
77
  readonly refreshToken?: (credentials: OAuthCredentials) => Promise<OAuthCredentials>;
package/src/stream.ts CHANGED
@@ -60,7 +60,7 @@ import {
60
60
  streamOpenAIResponses,
61
61
  } from "./providers/register-builtins";
62
62
  import { isSyntheticModel, streamSynthetic } from "./providers/synthetic";
63
- import { PROVIDER_REGISTRY } from "./registry";
63
+ import { getProviderDefinition, PROVIDER_REGISTRY } from "./registry";
64
64
  import type {
65
65
  Api,
66
66
  AssistantMessage,
@@ -812,33 +812,37 @@ function streamDispatch<TApi extends Api>(
812
812
  } as GitLabDuoWorkflowOptions);
813
813
  }
814
814
 
815
- // Vertex AI uses Application Default Credentials, not API keys
815
+ // Vertex AI and Bedrock Converse authenticate outside the generic API-key path.
816
816
  if (model.api === "google-vertex") {
817
817
  return streamGoogleVertex(model as Model<"google-vertex">, context, requestOptions as GoogleVertexOptions);
818
- } else if (model.api === "bedrock-converse-stream") {
819
- // Bedrock doesn't have any API keys instead it sources credentials from standard AWS env variables or from given AWS profile.
818
+ }
819
+ if (model.api === "bedrock-converse-stream") {
820
820
  return streamBedrock(model as Model<"bedrock-converse-stream">, context, requestOptions as BedrockOptions);
821
821
  }
822
822
 
823
- const apiKey = requestOptions.apiKey || getEnvApiKey(model.provider);
823
+ const prepareRequest = getProviderDefinition(model.provider)?.prepareRequest;
824
+ const prepared = prepareRequest?.(model as Model<Api>, requestOptions as StreamOptions);
825
+ const providerModel = prepared?.model ?? (model as Model<Api>);
826
+ const preparedOptions = prepared?.options ?? (requestOptions as StreamOptions);
827
+ const apiKey = preparedOptions.apiKey || getEnvApiKey(providerModel.provider);
824
828
  if (!apiKey) {
825
- throw new AIError.MissingApiKeyError(model.provider);
829
+ throw new AIError.MissingApiKeyError(providerModel.provider);
826
830
  }
827
- const providerOptions = isGoogleVertexAuthenticatedModel(model)
831
+ const providerOptions = isGoogleVertexAuthenticatedModel(providerModel)
828
832
  ? {
829
- ...requestOptions,
833
+ ...preparedOptions,
830
834
  apiKey: "vertex-adc",
831
- fetch: createVertexAuthenticatedFetch(requestOptions),
835
+ fetch: createVertexAuthenticatedFetch(preparedOptions),
832
836
  }
833
- : { ...requestOptions, apiKey };
837
+ : { ...preparedOptions, apiKey };
834
838
 
835
- const api: Api = model.api;
839
+ const api: Api = providerModel.api;
836
840
  switch (api) {
837
841
  case "anthropic-messages": {
838
842
  const anthropicOptions = providerOptions as AnthropicOptions;
839
- return streamAnthropic(model as Model<"anthropic-messages">, context, {
843
+ return streamAnthropic(providerModel as Model<"anthropic-messages">, context, {
840
844
  ...anthropicOptions,
841
- isOAuth: anthropicOptions.isOAuth ?? model.isOAuth,
845
+ isOAuth: anthropicOptions.isOAuth ?? providerModel.isOAuth,
842
846
  });
843
847
  }
844
848
 
@@ -846,13 +850,13 @@ function streamDispatch<TApi extends Api>(
846
850
  const useResponses = $env.PI_OPENROUTER_RESPONSES !== "0";
847
851
  if (useResponses) {
848
852
  return streamOpenAIResponses(
849
- model as Model<"openai-responses">,
853
+ providerModel as Model<"openai-responses">,
850
854
  context,
851
855
  providerOptions as OptionsForApi<"openai-responses">,
852
856
  );
853
857
  }
854
858
  return streamOpenAICompletions(
855
- model as Model<"openai-completions">,
859
+ providerModel as Model<"openai-completions">,
856
860
  context,
857
861
  providerOptions as OptionsForApi<"openai-completions">,
858
862
  );
@@ -860,50 +864,50 @@ function streamDispatch<TApi extends Api>(
860
864
 
861
865
  case "openai-completions":
862
866
  return streamOpenAICompletions(
863
- model as Model<"openai-completions">,
867
+ providerModel as Model<"openai-completions">,
864
868
  context,
865
869
  providerOptions as OptionsForApi<"openai-completions">,
866
870
  );
867
871
 
868
872
  case "openai-responses":
869
873
  return streamOpenAIResponses(
870
- model as Model<"openai-responses">,
874
+ providerModel as Model<"openai-responses">,
871
875
  context,
872
876
  providerOptions as OptionsForApi<"openai-responses">,
873
877
  );
874
878
 
875
879
  case "azure-openai-responses":
876
880
  return streamAzureOpenAIResponses(
877
- model as Model<"azure-openai-responses">,
881
+ providerModel as Model<"azure-openai-responses">,
878
882
  context,
879
883
  providerOptions as OptionsForApi<"azure-openai-responses">,
880
884
  );
881
885
 
882
886
  case "openai-codex-responses":
883
887
  return streamOpenAICodexResponses(
884
- model as Model<"openai-codex-responses">,
888
+ providerModel as Model<"openai-codex-responses">,
885
889
  context,
886
890
  providerOptions as OptionsForApi<"openai-codex-responses">,
887
891
  );
888
892
 
889
893
  case "google-generative-ai":
890
- return streamGoogle(model as Model<"google-generative-ai">, context, providerOptions);
894
+ return streamGoogle(providerModel as Model<"google-generative-ai">, context, providerOptions);
891
895
 
892
896
  case "google-gemini-cli":
893
897
  return streamGoogleGeminiCli(
894
- model as Model<"google-gemini-cli">,
898
+ providerModel as Model<"google-gemini-cli">,
895
899
  context,
896
900
  providerOptions as GoogleGeminiCliOptions,
897
901
  );
898
902
 
899
903
  case "ollama-chat":
900
- return streamOllama(model as Model<"ollama-chat">, context, providerOptions as OllamaChatOptions);
904
+ return streamOllama(providerModel as Model<"ollama-chat">, context, providerOptions as OllamaChatOptions);
901
905
 
902
906
  case "cursor-agent":
903
- return streamCursor(model as Model<"cursor-agent">, context, providerOptions as CursorOptions);
907
+ return streamCursor(providerModel as Model<"cursor-agent">, context, providerOptions as CursorOptions);
904
908
 
905
909
  case "devin-agent":
906
- return streamDevin(model as Model<"devin-agent">, context, providerOptions as DevinOptions);
910
+ return streamDevin(providerModel as Model<"devin-agent">, context, providerOptions as DevinOptions);
907
911
 
908
912
  default:
909
913
  throw new AIError.ConfigurationError(`Unhandled API: ${api}`);
@@ -1028,12 +1032,10 @@ export function streamSimple<TApi extends Api>(
1028
1032
  if (apiKeyResolver) {
1029
1033
  const outer = new AssistantMessageEventStream();
1030
1034
  const signal = requestOptions?.signal;
1031
- // One inner attempt against a resolved string key. A retryable auth error
1032
- // that arrives before any replay-unsafe event is buffered and returned
1033
- // (so the caller can retry with a fresh key) instead of surfaced. Once any
1034
- // non-start event escapes, retry is no longer safe and the failure is
1035
- // emitted directly.
1036
- const runAttempt = async (apiKey: string): Promise<AuthRetryFailure | undefined> => {
1035
+ // One inner attempt against a resolved key, or against the Bedrock AWS
1036
+ // credential chain when its optional resolver has no stored bearer key.
1037
+ // Retryable auth failures are buffered until replay is safe.
1038
+ const runAttempt = async (apiKey?: string): Promise<AuthRetryFailure | undefined> => {
1037
1039
  const bufferedEvents: AssistantMessageEvent[] = [];
1038
1040
  let emittedReplayUnsafeEvent = false;
1039
1041
  const flushBuffered = (): void => {
@@ -1107,6 +1109,11 @@ export function streamSimple<TApi extends Api>(
1107
1109
  return;
1108
1110
  }
1109
1111
  if (lastKey === undefined) {
1112
+ if (getProviderDefinition(model.provider)?.allowsMissingApiKey) {
1113
+ const failure = await runAttempt();
1114
+ if (failure) emitFailure(failure);
1115
+ return;
1116
+ }
1110
1117
  outer.fail(new AIError.MissingApiKeyError(model.provider));
1111
1118
  return;
1112
1119
  }
@@ -1156,6 +1163,13 @@ export function streamSimple<TApi extends Api>(
1156
1163
  // Bedrock doesn't have any API keys instead it sources credentials from standard AWS env variables or from given AWS profile.
1157
1164
  const providerOptions = mapOptionsForApi(model, requestOptions, undefined);
1158
1165
  return stream(model, context, providerOptions);
1166
+ } else if (getProviderDefinition(model.provider)?.allowsMissingApiKey) {
1167
+ const providerOptions = mapOptionsForApi(
1168
+ model,
1169
+ requestOptions,
1170
+ typeof requestOptions.apiKey === "string" ? requestOptions.apiKey : getEnvApiKey(model.provider),
1171
+ );
1172
+ return stream(model, context, providerOptions);
1159
1173
  }
1160
1174
 
1161
1175
  // The resolver form is handled by the wrapper above; only a static string
@@ -1454,6 +1468,7 @@ function mapOptionsForApi<TApi extends Api>(
1454
1468
  apiKey?: string,
1455
1469
  ): OptionsForApi<TApi> {
1456
1470
  const options = normalizeMandatoryReasoningOptions(model, rawOptions);
1471
+ const simpleProviderOptions = getProviderDefinition(model.provider)?.mapSimpleOptions?.(options ?? {});
1457
1472
  const base = {
1458
1473
  temperature: options?.temperature,
1459
1474
  topP: options?.topP,
@@ -1483,6 +1498,7 @@ function mapOptionsForApi<TApi extends Api>(
1483
1498
  execHandlers: options?.execHandlers,
1484
1499
  fetch: options?.fetch,
1485
1500
  fallbacks: options?.fallbacks,
1501
+ ...simpleProviderOptions,
1486
1502
  };
1487
1503
 
1488
1504
  switch (model.api) {