@gajae-code/ai 0.9.6 → 0.10.0

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,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.10.0] - 2026-07-12
6
+ ### Fixed
7
+
8
+ - Made Bedrock model visibility reflect credential-only static/shared AWS sources with supported profile shapes, authenticated real bearer-token requests, and stopped advertising unsupported ECS/IRSA sources (#1934).
9
+ - Added a typed provider safety-stop classification across Anthropic, OpenAI-compatible, and Google streams so callers can distinguish policy terminations from generic provider errors without parsing display text.
10
+
5
11
  ## [0.9.6] - 2026-07-10
6
12
  ### Fixed
7
13
 
@@ -0,0 +1,19 @@
1
+ import type { AwsCredentials } from "./aws-sigv4";
2
+ export type AwsIniFile = Record<string, Record<string, string>>;
3
+ export interface AwsCredentialSourceOptions {
4
+ profile?: string;
5
+ }
6
+ export interface AwsCredentialSource {
7
+ profile: string;
8
+ credentialsPath: string;
9
+ configPath: string;
10
+ }
11
+ export type AwsProfileCapability = "static" | "process" | "sso" | undefined;
12
+ export declare function parseAwsIni(text: string): AwsIniFile;
13
+ export declare function resolveAwsCredentialSource(options?: AwsCredentialSourceOptions): AwsCredentialSource;
14
+ export declare function readAwsStaticEnvironmentCredentials(): AwsCredentials | undefined;
15
+ export declare function classifyAwsProfileCapability(profile: string, credentialsIni: AwsIniFile | undefined, configIni: AwsIniFile | undefined): AwsProfileCapability;
16
+ export declare function hasResolvableAwsProfileSource(options?: AwsCredentialSourceOptions & {
17
+ onScan?: () => void;
18
+ }, now?: number): boolean;
19
+ export declare function isValidBedrockBearerToken(token: string | undefined): token is string;
@@ -8,6 +8,12 @@ import type { Content, FinishReason, FunctionCallingConfigMode, GenerateContentP
8
8
  export type { Content, FunctionCallingConfigMode, GenerateContentParameters, GenerateContentResponse, ThinkingConfig, } from "./google-types";
9
9
  export { normalizeSchemaForGoogle };
10
10
  type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex";
11
+ export declare const PROVIDER_SAFETY_STOP = "provider_safety_stop";
12
+ export declare function isGoogleCandidateSafetyStopReason(reason: string): boolean;
13
+ export declare function isGooglePromptSafetyStopReason(reason: string): boolean;
14
+ export declare function getGooglePromptBlockReason(promptFeedback: {
15
+ blockReason?: unknown;
16
+ } | undefined): string | undefined;
11
17
  /**
12
18
  * Thinking level for Gemini 3 models. Mirrors Google's `ThinkingLevel` enum values.
13
19
  * Defined here (not in any specific provider) so all Google providers can reference it
@@ -393,6 +393,7 @@ export interface Usage {
393
393
  };
394
394
  }
395
395
  export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted";
396
+ export type AssistantErrorKind = "provider_safety_stop";
396
397
  export interface OpenAIResponsesHistoryPayload {
397
398
  type: "openaiResponsesHistory";
398
399
  provider?: string;
@@ -430,6 +431,7 @@ export interface AssistantMessage {
430
431
  usage: Usage;
431
432
  stopReason: StopReason;
432
433
  errorMessage?: string;
434
+ errorKind?: AssistantErrorKind;
433
435
  /** HTTP status surfaced by the provider when the request failed. Populated by every provider's catch block alongside `errorMessage` so consumers (auth retry, telemetry, UI) can branch without regex-scraping the message. */
434
436
  errorStatus?: number;
435
437
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/ai",
4
- "version": "0.9.6",
4
+ "version": "0.10.0",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "scripts": {
32
32
  "check": "biome check . && bun run check:types",
33
- "check:types": "tsgo -p tsconfig.json --noEmit",
33
+ "check:types": "tsc -p tsconfig.json --noEmit",
34
34
  "lint": "biome lint .",
35
35
  "test": "bun test",
36
36
  "fix": "biome check --write --unsafe .",
@@ -40,7 +40,7 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/sdk": "^0.94.0",
42
42
  "@bufbuild/protobuf": "^2.12.0",
43
- "@gajae-code/utils": "0.9.6",
43
+ "@gajae-code/utils": "0.10.0",
44
44
  "openai": "^6.36.0",
45
45
  "partial-json": "^0.1.7",
46
46
  "zod": "4.4.3"
@@ -7,7 +7,7 @@
7
7
  * Bun's native `HTTPS_PROXY` support.
8
8
  */
9
9
 
10
- import { $env, $flag, extractHttpStatusFromError, fetchWithRetry } from "@gajae-code/utils";
10
+ import { $credentialEnv, $env, $flag, extractHttpStatusFromError, fetchWithRetry } from "@gajae-code/utils";
11
11
  import type { Effort } from "../model-thinking";
12
12
  import { mapEffortToAnthropicAdaptiveEffort, requireSupportedEffort } from "../model-thinking";
13
13
  import { calculateCost } from "../models";
@@ -39,8 +39,10 @@ import {
39
39
  markToolChoiceIncapability,
40
40
  resolveToolChoice,
41
41
  } from "../utils/tool-choice-capability";
42
+ import { isValidBedrockBearerToken } from "./aws-credential-config";
42
43
  import { resolveAwsCredentials } from "./aws-credentials";
43
44
  import { decodeEventStream } from "./aws-eventstream";
45
+ import type { AwsCredentials } from "./aws-sigv4";
44
46
  import { signRequest } from "./aws-sigv4";
45
47
  import { transformMessages } from "./transform-messages";
46
48
 
@@ -162,6 +164,8 @@ interface MetadataEvent {
162
164
  };
163
165
  }
164
166
 
167
+ type BedrockAuthMode = { kind: "bearer"; token: string } | { kind: "sigv4"; credentials: AwsCredentials };
168
+
165
169
  export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
166
170
  model: Model<"bedrock-converse-stream">,
167
171
  context: Context,
@@ -233,34 +237,44 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
233
237
  body: commandInput,
234
238
  };
235
239
 
236
- let credentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string };
237
- if ($flag("AWS_BEDROCK_SKIP_AUTH")) {
238
- credentials = { accessKeyId: "dummy-access-key", secretAccessKey: "dummy-secret-key" };
239
- } else {
240
- credentials = await resolveAwsCredentials({
241
- profile: options.profile,
242
- region,
243
- signal: options.signal,
244
- });
240
+ const bearerToken = $credentialEnv("AWS_BEARER_TOKEN_BEDROCK");
241
+ if (bearerToken && !isValidBedrockBearerToken(bearerToken)) {
242
+ throw new Error("AWS_BEARER_TOKEN_BEDROCK contains unsafe control characters.");
245
243
  }
246
-
244
+ const authMode: BedrockAuthMode = bearerToken
245
+ ? { kind: "bearer", token: bearerToken }
246
+ : {
247
+ kind: "sigv4",
248
+ credentials: $flag("AWS_BEDROCK_SKIP_AUTH")
249
+ ? { accessKeyId: "dummy-access-key", secretAccessKey: "dummy-secret-key" }
250
+ : await resolveAwsCredentials({ profile: options.profile, region, signal: options.signal }),
251
+ };
247
252
  const bodyText = JSON.stringify(commandInput);
248
253
  const body = new TextEncoder().encode(bodyText);
249
254
  const baseHeaders: Record<string, string> = {
250
255
  "content-type": "application/json",
251
256
  accept: "application/vnd.amazon.eventstream",
252
257
  };
253
- const signed = await signRequest({
254
- method: "POST",
255
- host,
256
- path: urlPath,
257
- body,
258
- region,
259
- service: "bedrock",
260
- credentials,
261
- headers: baseHeaders,
262
- });
263
- const requestHeaders: Record<string, string> = { ...baseHeaders, ...signed };
258
+ const buildRequestHeaders = async (requestBody: Uint8Array): Promise<Record<string, string>> => {
259
+ const headers = new Headers(baseHeaders);
260
+ if (authMode.kind === "bearer") {
261
+ headers.set("authorization", `Bearer ${authMode.token}`);
262
+ return Object.fromEntries(headers);
263
+ }
264
+ const signed = await signRequest({
265
+ method: "POST",
266
+ host,
267
+ path: urlPath,
268
+ body: requestBody,
269
+ region,
270
+ service: "bedrock",
271
+ credentials: authMode.credentials,
272
+ headers: baseHeaders,
273
+ });
274
+ for (const [name, value] of Object.entries(signed)) headers.set(name, value);
275
+ return Object.fromEntries(headers);
276
+ };
277
+ const requestHeaders = await buildRequestHeaders(body);
264
278
  const sentForcedToolChoice = Boolean(toolConfig?.toolChoice?.any || toolConfig?.toolChoice?.tool);
265
279
  let fallbackRan = false;
266
280
  const retryWithoutForcedToolChoice = async (reason: string) => {
@@ -279,20 +293,10 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
279
293
  stripBedrockForcedToolChoiceForRetry(commandInput);
280
294
  const retryBodyText = JSON.stringify(commandInput);
281
295
  const retryBody = new TextEncoder().encode(retryBodyText);
282
- const retrySigned = await signRequest({
283
- method: "POST",
284
- host,
285
- path: urlPath,
286
- body: retryBody,
287
- region,
288
- service: "bedrock",
289
- credentials,
290
- headers: baseHeaders,
291
- });
292
296
  if (rawRequestDump) rawRequestDump.body = commandInput;
293
297
  return fetchWithRetry(url, {
294
298
  method: "POST",
295
- headers: { ...baseHeaders, ...retrySigned },
299
+ headers: await buildRequestHeaders(retryBody),
296
300
  body: retryBody,
297
301
  signal: options.signal,
298
302
  maxAttempts: 1,
@@ -1341,6 +1341,17 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1341
1341
  }
1342
1342
  blocksByAnthropicIndex.set(anthropicIndex, block);
1343
1343
  };
1344
+ const resetOutputForRetry = () => {
1345
+ output.content.length = 0;
1346
+ output.responseId = undefined;
1347
+ output.errorKind = undefined;
1348
+ output.errorStatus = undefined;
1349
+ output.errorMessage = strictFallbackErrorMessage;
1350
+ output.providerPayload = undefined;
1351
+ output.usage = createEmptyUsage(copilotDynamicHeaders?.premiumRequests);
1352
+ output.stopReason = "stop";
1353
+ firstTokenTime = undefined;
1354
+ };
1344
1355
  const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs();
1345
1356
  const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
1346
1357
  stream.push({ type: "start", partial: output });
@@ -1360,6 +1371,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1360
1371
  const { requestSignal } = activeAbortTracker;
1361
1372
  const anthropicRequest = client.messages.create({ ...params, stream: true }, { signal: requestSignal });
1362
1373
  let streamedReplayUnsafeContent = false;
1374
+ let sawProviderSafetyStop = false;
1363
1375
 
1364
1376
  try {
1365
1377
  const {
@@ -1386,6 +1398,12 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1386
1398
  abortSignal: options?.signal,
1387
1399
  })) {
1388
1400
  sawEvent = true;
1401
+ if (sawProviderSafetyStop) {
1402
+ if (event.type === "message_stop") {
1403
+ sawTerminalEnvelope = true;
1404
+ }
1405
+ continue;
1406
+ }
1389
1407
 
1390
1408
  if (event.type === "message_start") {
1391
1409
  if (sawMessageStart) {
@@ -1545,26 +1563,34 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1545
1563
  }
1546
1564
  } else if (event.type === "message_delta") {
1547
1565
  const rawStopReason = event.delta.stop_reason as string | null | undefined;
1566
+ const stopDetails = event.delta.stop_details;
1567
+ const isProviderSafetyStop =
1568
+ rawStopReason === "refusal" || rawStopReason === "sensitive" || stopDetails?.type === "refusal";
1548
1569
  if (rawStopReason) {
1549
- output.stopReason = mapStopReason(rawStopReason);
1570
+ output.stopReason = isProviderSafetyStop ? "error" : mapStopReason(rawStopReason);
1550
1571
  sawTerminalEnvelope = true;
1551
1572
  }
1552
- const stopDetails = event.delta.stop_details;
1553
- if (stopDetails && stopDetails.type === "refusal") {
1554
- const explanation = stopDetails.explanation?.trim();
1555
- const category = stopDetails.category;
1556
- const label = category ? `Refusal (${category})` : "Refusal";
1557
- output.errorMessage = explanation ? `${label}: ${explanation}` : label;
1573
+ if (isProviderSafetyStop) {
1574
+ sawProviderSafetyStop = true;
1575
+ sawTerminalEnvelope = true;
1576
+ output.stopReason = "error";
1577
+ output.errorKind = "provider_safety_stop";
1578
+ if (stopDetails?.type === "refusal") {
1579
+ const explanation = stopDetails.explanation?.trim();
1580
+ const category = stopDetails.category;
1581
+ const label = category ? `Refusal (${category})` : "Refusal";
1582
+ output.errorMessage = explanation ? `${label}: ${explanation}` : label;
1583
+ } else if (!output.errorMessage) {
1584
+ output.errorMessage =
1585
+ rawStopReason === "refusal"
1586
+ ? "Refusal (no details provided)"
1587
+ : "Content flagged by safety filters";
1588
+ }
1558
1589
  } else if (output.stopReason === "error" && !output.errorMessage) {
1559
- // Anthropic flagged an error-class stop (refusal / sensitive) without
1560
- // populating stop_details. Surface the raw reason instead of falling
1561
- // through to the generic "unknown error" string when we throw below.
1562
- output.errorMessage =
1563
- rawStopReason === "refusal"
1564
- ? "Refusal (no details provided)"
1565
- : rawStopReason === "sensitive"
1566
- ? "Content flagged by safety filters"
1567
- : `Anthropic stream ended with stop_reason: ${rawStopReason ?? "unknown"}`;
1590
+ // Anthropic flagged an error-class stop without populating stop_details.
1591
+ // Surface the raw reason instead of falling through to the generic
1592
+ // "unknown error" string when we throw below.
1593
+ output.errorMessage = `Anthropic stream ended with stop_reason: ${rawStopReason ?? "unknown"}`;
1568
1594
  }
1569
1595
  if (event.usage.input_tokens != null) {
1570
1596
  output.usage.input = event.usage.input_tokens;
@@ -1607,6 +1633,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1607
1633
  break;
1608
1634
  } catch (streamError) {
1609
1635
  const streamFailure = activeAbortTracker.getLocalAbortReason() ?? streamError;
1636
+ if (sawProviderSafetyStop) {
1637
+ throw streamFailure;
1638
+ }
1610
1639
  if (
1611
1640
  !disableStrictTools &&
1612
1641
  firstTokenTime === undefined &&
@@ -1614,19 +1643,13 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1614
1643
  isAnthropicStrictGrammarTooLargeError(streamFailure)
1615
1644
  ) {
1616
1645
  strictFallbackErrorMessage = await finalizeErrorMessage(streamFailure, rawRequestDump);
1617
- output.errorMessage = strictFallbackErrorMessage;
1618
1646
  if (providerSessionState) {
1619
1647
  providerSessionState.strictToolsDisabled = true;
1620
1648
  }
1621
1649
  disableStrictTools = true;
1622
1650
  params = await prepareParams();
1623
1651
  providerRetryAttempt = 0;
1624
- output.content.length = 0;
1625
- output.responseId = undefined;
1626
- output.providerPayload = undefined;
1627
- output.usage = createEmptyUsage(copilotDynamicHeaders?.premiumRequests);
1628
- output.stopReason = "stop";
1629
- firstTokenTime = undefined;
1652
+ resetOutputForRetry();
1630
1653
  continue;
1631
1654
  }
1632
1655
  if (
@@ -1654,12 +1677,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1654
1677
  droppedForcedToolChoice = true;
1655
1678
  params = await prepareParams({ dropForcedToolChoice: true });
1656
1679
  providerRetryAttempt = 0;
1657
- output.content.length = 0;
1658
- output.responseId = undefined;
1659
- output.providerPayload = undefined;
1660
- output.usage = createEmptyUsage(copilotDynamicHeaders?.premiumRequests);
1661
- output.stopReason = "stop";
1662
- firstTokenTime = undefined;
1680
+ resetOutputForRetry();
1663
1681
  continue;
1664
1682
  }
1665
1683
  if (
@@ -1674,12 +1692,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1674
1692
  thinkingRepairAttempted = true;
1675
1693
  params = await prepareParams({ repairLatestAssistantThinking: true });
1676
1694
  providerRetryAttempt = 0;
1677
- output.content.length = 0;
1678
- output.responseId = undefined;
1679
- output.providerPayload = undefined;
1680
- output.usage = createEmptyUsage(copilotDynamicHeaders?.premiumRequests);
1681
- output.stopReason = "stop";
1682
- firstTokenTime = undefined;
1695
+ resetOutputForRetry();
1683
1696
  continue;
1684
1697
  }
1685
1698
  if (
@@ -1698,12 +1711,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1698
1711
  dropFastMode = true;
1699
1712
  params = await prepareParams();
1700
1713
  providerRetryAttempt = 0;
1701
- output.content.length = 0;
1702
- output.responseId = undefined;
1703
- output.providerPayload = undefined;
1704
- output.usage = createEmptyUsage(copilotDynamicHeaders?.premiumRequests);
1705
- output.stopReason = "stop";
1706
- firstTokenTime = undefined;
1714
+ resetOutputForRetry();
1707
1715
  continue;
1708
1716
  }
1709
1717
  const isTransientEnvelopeFailure =
@@ -1725,13 +1733,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1725
1733
  } else {
1726
1734
  await scheduler.wait(delayMs, { signal: options?.signal });
1727
1735
  }
1728
- output.content.length = 0;
1729
- output.responseId = undefined;
1730
- output.errorMessage = strictFallbackErrorMessage;
1731
- output.providerPayload = undefined;
1732
- output.usage = createEmptyUsage(copilotDynamicHeaders?.premiumRequests);
1733
- output.stopReason = "stop";
1734
- firstTokenTime = undefined;
1736
+ resetOutputForRetry();
1735
1737
  }
1736
1738
  }
1737
1739
 
@@ -1750,7 +1752,10 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1750
1752
  const firstEventTimeoutError = activeAbortTracker.getLocalAbortReason();
1751
1753
  output.stopReason = activeAbortTracker.wasCallerAbort() ? "aborted" : "error";
1752
1754
  output.errorStatus = extractHttpStatusFromError(error);
1753
- output.errorMessage = firstEventTimeoutError?.message ?? (await finalizeErrorMessage(error, rawRequestDump));
1755
+ if (output.errorKind !== "provider_safety_stop" || !output.errorMessage) {
1756
+ output.errorMessage =
1757
+ firstEventTimeoutError?.message ?? (await finalizeErrorMessage(error, rawRequestDump));
1758
+ }
1754
1759
  output.errorMessage = rewriteCopilotError(output.errorMessage, error, model.provider);
1755
1760
  output.duration = Date.now() - startTime;
1756
1761
  if (firstTokenTime) output.ttft = firstTokenTime - startTime;
@@ -0,0 +1,180 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { $credentialEnv } from "@gajae-code/utils";
5
+ import type { AwsCredentials } from "./aws-sigv4";
6
+
7
+ export type AwsIniFile = Record<string, Record<string, string>>;
8
+
9
+ export interface AwsCredentialSourceOptions {
10
+ profile?: string;
11
+ }
12
+
13
+ export interface AwsCredentialSource {
14
+ profile: string;
15
+ credentialsPath: string;
16
+ configPath: string;
17
+ }
18
+
19
+ export type AwsProfileCapability = "static" | "process" | "sso" | undefined;
20
+
21
+ const AVAILABILITY_CACHE_MAX_AGE_MS = 1_000;
22
+ const MAX_AWS_INI_FILE_BYTES = 1024 * 1024;
23
+
24
+ interface FileFingerprint {
25
+ exists: boolean;
26
+ size?: number;
27
+ mtimeMs?: number;
28
+ ctimeMs?: number;
29
+ ino?: number;
30
+ }
31
+
32
+ interface AvailabilityCacheEntry {
33
+ source: AwsCredentialSource;
34
+ credentials: FileFingerprint;
35
+ config: FileFingerprint;
36
+ value: boolean;
37
+ checkedAt: number;
38
+ }
39
+
40
+ let availabilityCache: AvailabilityCacheEntry | undefined;
41
+
42
+ export function parseAwsIni(text: string): AwsIniFile {
43
+ const out: AwsIniFile = {};
44
+ let current: Record<string, string> | undefined;
45
+ for (const rawLine of text.split(/\r?\n/)) {
46
+ const line = rawLine.trim();
47
+ if (!line || line.startsWith("#") || line.startsWith(";")) continue;
48
+ if (line.startsWith("[") && line.endsWith("]")) {
49
+ let name = line.slice(1, -1).trim();
50
+ if (name.startsWith("profile ")) name = name.slice(8).trim();
51
+ if (name.startsWith("sso-session ")) name = `sso-session:${name.slice(12).trim()}`;
52
+ current = out[name] ??= {};
53
+ continue;
54
+ }
55
+ if (!current) continue;
56
+ const equals = line.indexOf("=");
57
+ if (equals === -1) continue;
58
+ const key = line.slice(0, equals).trim();
59
+ const value = line.slice(equals + 1).trim();
60
+ if (key) current[key] = value;
61
+ }
62
+ return out;
63
+ }
64
+
65
+ export function resolveAwsCredentialSource(options: AwsCredentialSourceOptions = {}): AwsCredentialSource {
66
+ const profile = options.profile || $credentialEnv("AWS_PROFILE") || "default";
67
+ const home = os.homedir();
68
+ return {
69
+ profile,
70
+ credentialsPath: path.resolve(
71
+ $credentialEnv("AWS_SHARED_CREDENTIALS_FILE") || path.join(home, ".aws", "credentials"),
72
+ ),
73
+ configPath: path.resolve($credentialEnv("AWS_CONFIG_FILE") || path.join(home, ".aws", "config")),
74
+ };
75
+ }
76
+
77
+ export function readAwsStaticEnvironmentCredentials(): AwsCredentials | undefined {
78
+ const accessKeyId = $credentialEnv("AWS_ACCESS_KEY_ID");
79
+ const secretAccessKey = $credentialEnv("AWS_SECRET_ACCESS_KEY");
80
+ if (!accessKeyId || !secretAccessKey) return undefined;
81
+ const sessionToken = $credentialEnv("AWS_SESSION_TOKEN");
82
+ return sessionToken ? { accessKeyId, secretAccessKey, sessionToken } : { accessKeyId, secretAccessKey };
83
+ }
84
+
85
+ export function classifyAwsProfileCapability(
86
+ profile: string,
87
+ credentialsIni: AwsIniFile | undefined,
88
+ configIni: AwsIniFile | undefined,
89
+ ): AwsProfileCapability {
90
+ const merged = { ...(configIni?.[profile] ?? {}), ...(credentialsIni?.[profile] ?? {}) };
91
+ if (merged.aws_access_key_id && merged.aws_secret_access_key) return "static";
92
+ if (merged.sso_account_id && merged.sso_role_name) {
93
+ if (merged.sso_start_url && merged.sso_region) return "sso";
94
+ const session = merged.sso_session ? configIni?.[`sso-session:${merged.sso_session}`] : undefined;
95
+ if (session?.sso_start_url && session.sso_region) return "sso";
96
+ }
97
+ if (merged.credential_process) return "process";
98
+ return undefined;
99
+ }
100
+
101
+ export function hasResolvableAwsProfileSource(
102
+ options: AwsCredentialSourceOptions & { /** @internal Test-only cache scan observer. */ onScan?: () => void } = {},
103
+ now = Date.now(),
104
+ ): boolean {
105
+ const source = resolveAwsCredentialSource(options);
106
+ const credentials = fingerprint(source.credentialsPath);
107
+ const config = fingerprint(source.configPath);
108
+ if (
109
+ availabilityCache &&
110
+ now >= availabilityCache.checkedAt &&
111
+ now - availabilityCache.checkedAt < AVAILABILITY_CACHE_MAX_AGE_MS &&
112
+ sameSource(availabilityCache.source, source) &&
113
+ sameFingerprint(availabilityCache.credentials, credentials) &&
114
+ sameFingerprint(availabilityCache.config, config)
115
+ ) {
116
+ return availabilityCache.value;
117
+ }
118
+ options.onScan?.();
119
+ const credentialsIni = readAwsIniSync(source.credentialsPath);
120
+ const configIni = readAwsIniSync(source.configPath);
121
+ const value = classifyAwsProfileCapability(source.profile, credentialsIni, configIni) !== undefined;
122
+ availabilityCache = { source, credentials, config, value, checkedAt: now };
123
+ return value;
124
+ }
125
+
126
+ export function isValidBedrockBearerToken(token: string | undefined): token is string {
127
+ if (!token) return false;
128
+ return !/[\x00-\x1f\x7f]/.test(token);
129
+ }
130
+
131
+ function readAwsIniSync(filePath: string): AwsIniFile | undefined {
132
+ let fd: number | undefined;
133
+ try {
134
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
135
+ const stat = fs.fstatSync(fd);
136
+ if (!stat.isFile() || stat.size > MAX_AWS_INI_FILE_BYTES) return undefined;
137
+ const contents = Buffer.allocUnsafe(MAX_AWS_INI_FILE_BYTES + 1);
138
+ let bytesRead = 0;
139
+ while (bytesRead < contents.length) {
140
+ const count = fs.readSync(fd, contents, bytesRead, contents.length - bytesRead, bytesRead);
141
+ if (count === 0) break;
142
+ bytesRead += count;
143
+ }
144
+ if (bytesRead > MAX_AWS_INI_FILE_BYTES) return undefined;
145
+ return parseAwsIni(contents.toString("utf8", 0, bytesRead));
146
+ } catch {
147
+ return undefined;
148
+ } finally {
149
+ if (fd !== undefined) {
150
+ try {
151
+ fs.closeSync(fd);
152
+ } catch {
153
+ // Ignore close errors because file availability has already been determined.
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ function fingerprint(filePath: string): FileFingerprint {
160
+ try {
161
+ const stat = fs.statSync(filePath);
162
+ return { exists: true, size: stat.size, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, ino: stat.ino };
163
+ } catch {
164
+ return { exists: false };
165
+ }
166
+ }
167
+
168
+ function sameSource(a: AwsCredentialSource, b: AwsCredentialSource): boolean {
169
+ return a.profile === b.profile && a.credentialsPath === b.credentialsPath && a.configPath === b.configPath;
170
+ }
171
+
172
+ function sameFingerprint(a: FileFingerprint, b: FileFingerprint): boolean {
173
+ return (
174
+ a.exists === b.exists &&
175
+ a.size === b.size &&
176
+ a.mtimeMs === b.mtimeMs &&
177
+ a.ctimeMs === b.ctimeMs &&
178
+ a.ino === b.ino
179
+ );
180
+ }
@@ -23,6 +23,13 @@ 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 "@gajae-code/utils";
26
+ import {
27
+ type AwsIniFile,
28
+ classifyAwsProfileCapability,
29
+ parseAwsIni,
30
+ readAwsStaticEnvironmentCredentials,
31
+ resolveAwsCredentialSource,
32
+ } from "./aws-credential-config";
26
33
  import type { AwsCredentials } from "./aws-sigv4";
27
34
 
28
35
  export interface ResolvedCredentials extends AwsCredentials {
@@ -48,7 +55,7 @@ interface CacheEntry {
48
55
  const cache: Map<string, CacheEntry> = new Map();
49
56
 
50
57
  export async function resolveAwsCredentials(opts: CredentialResolveOptions = {}): Promise<ResolvedCredentials> {
51
- const profile = opts.profile || $env.AWS_PROFILE || "default";
58
+ const profile = resolveAwsCredentialSource({ profile: opts.profile }).profile;
52
59
  const region = opts.region || $env.AWS_REGION || $env.AWS_DEFAULT_REGION || "us-east-1";
53
60
  const cacheKey = `${profile}\x00${region}`;
54
61
 
@@ -62,7 +69,7 @@ export async function resolveAwsCredentials(opts: CredentialResolveOptions = {})
62
69
 
63
70
  async function resolveFresh(profile: string, region: string, signal?: AbortSignal): Promise<ResolvedCredentials> {
64
71
  // 1. Environment first — matches the AWS SDK chain order.
65
- const envCreds = readEnvCredentials();
72
+ const envCreds = readAwsStaticEnvironmentCredentials();
66
73
  if (envCreds) return envCreds;
67
74
 
68
75
  // 2. Profile (static or SSO).
@@ -81,52 +88,10 @@ async function resolveFresh(profile: string, region: string, signal?: AbortSigna
81
88
  );
82
89
  }
83
90
 
84
- function readEnvCredentials(): ResolvedCredentials | undefined {
85
- const ak = $env.AWS_ACCESS_KEY_ID;
86
- const sk = $env.AWS_SECRET_ACCESS_KEY;
87
- if (!ak || !sk) return undefined;
88
- const token = $env.AWS_SESSION_TOKEN;
89
- return token
90
- ? { accessKeyId: ak, secretAccessKey: sk, sessionToken: token }
91
- : { accessKeyId: ak, secretAccessKey: sk };
92
- }
93
-
94
- // ---------- INI parsing ----------
95
-
96
- /** Map of section name -> map of key -> value. Section names are stripped of
97
- * any leading `profile ` (so `~/.aws/config` aligns with `~/.aws/credentials`). */
98
- type IniFile = Record<string, Record<string, string>>;
99
-
100
- function parseIni(text: string): IniFile {
101
- const out: IniFile = {};
102
- let current: Record<string, string> | null = null;
103
- for (const rawLine of text.split(/\r?\n/)) {
104
- const line = rawLine.trim();
105
- if (!line || line.startsWith("#") || line.startsWith(";")) continue;
106
- if (line.startsWith("[") && line.endsWith("]")) {
107
- let name = line.slice(1, -1).trim();
108
- if (name.startsWith("profile ")) name = name.slice(8).trim();
109
- if (name.startsWith("sso-session ")) name = `sso-session:${name.slice(12).trim()}`;
110
- let section = out[name];
111
- if (!section) {
112
- section = {};
113
- out[name] = section;
114
- }
115
- current = section;
116
- continue;
117
- }
118
- if (!current) continue;
119
- const eq = line.indexOf("=");
120
- if (eq === -1) continue;
121
- current[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
122
- }
123
- return out;
124
- }
125
-
126
- async function readIniFile(p: string): Promise<IniFile | undefined> {
91
+ async function readIniFile(p: string): Promise<AwsIniFile | undefined> {
127
92
  try {
128
93
  const text = await fs.promises.readFile(p, "utf8");
129
- return parseIni(text);
94
+ return parseAwsIni(text);
130
95
  } catch (err) {
131
96
  if (isEnoent(err)) return undefined;
132
97
  throw err;
@@ -140,9 +105,7 @@ async function readProfileCredentials(
140
105
  region: string,
141
106
  signal: AbortSignal | undefined,
142
107
  ): Promise<ResolvedCredentials | undefined> {
143
- const home = os.homedir();
144
- const credentialsPath = $env.AWS_SHARED_CREDENTIALS_FILE || path.join(home, ".aws", "credentials");
145
- const configPath = $env.AWS_CONFIG_FILE || path.join(home, ".aws", "config");
108
+ const { credentialsPath, configPath } = resolveAwsCredentialSource({ profile });
146
109
 
147
110
  const credentialsIni = await readIniFile(credentialsPath);
148
111
  const configIni = await readIniFile(configPath);
@@ -152,22 +115,16 @@ async function readProfileCredentials(
152
115
  const merged: Record<string, string> = { ...(configIni?.[profile] ?? {}), ...(credentialsIni?.[profile] ?? {}) };
153
116
  if (Object.keys(merged).length === 0) return undefined;
154
117
 
155
- if (merged.aws_access_key_id && merged.aws_secret_access_key) {
156
- const out: ResolvedCredentials = {
157
- accessKeyId: merged.aws_access_key_id,
158
- secretAccessKey: merged.aws_secret_access_key,
159
- };
118
+ const capability = classifyAwsProfileCapability(profile, credentialsIni, configIni);
119
+ if (capability === "static") {
120
+ const { aws_access_key_id: accessKeyId, aws_secret_access_key: secretAccessKey } = merged;
121
+ if (!accessKeyId || !secretAccessKey) return undefined;
122
+ const out: ResolvedCredentials = { accessKeyId, secretAccessKey };
160
123
  if (merged.aws_session_token) out.sessionToken = merged.aws_session_token;
161
124
  return out;
162
125
  }
163
-
164
- if (merged.sso_account_id && merged.sso_role_name) {
165
- return readSsoCredentials(merged, configIni, region, signal);
166
- }
167
-
168
- if (merged.credential_process) {
169
- return readCredentialProcess(profile, merged.credential_process, signal);
170
- }
126
+ if (capability === "sso") return readSsoCredentials(merged, configIni, region, signal);
127
+ if (capability === "process") return readCredentialProcess(profile, merged.credential_process, signal);
171
128
 
172
129
  return undefined;
173
130
  }
@@ -181,7 +138,7 @@ interface SsoCachedToken {
181
138
 
182
139
  async function readSsoCredentials(
183
140
  profileCfg: Record<string, string>,
184
- configIni: IniFile | undefined,
141
+ configIni: AwsIniFile | undefined,
185
142
  defaultRegion: string,
186
143
  signal: AbortSignal | undefined,
187
144
  ): Promise<ResolvedCredentials | undefined> {
@@ -40,10 +40,14 @@ import {
40
40
  convertMessages,
41
41
  convertTools,
42
42
  type GoogleThinkingLevel,
43
+ getGooglePromptBlockReason,
44
+ isGoogleCandidateSafetyStopReason,
45
+ isGooglePromptSafetyStopReason,
43
46
  isThinkingPart,
44
47
  mapStopReasonString,
45
48
  mapToolChoice,
46
49
  nextToolCallId,
50
+ PROVIDER_SAFETY_STOP,
47
51
  pushBlockEndEvent,
48
52
  pushToolCallEvents,
49
53
  retainThoughtSignature,
@@ -255,6 +259,9 @@ interface CloudCodeAssistResponseChunk {
255
259
  };
256
260
  finishReason?: string;
257
261
  }>;
262
+ promptFeedback?: {
263
+ blockReason?: string;
264
+ };
258
265
  usageMetadata?: {
259
266
  promptTokenCount?: number;
260
267
  candidatesTokenCount?: number;
@@ -537,9 +544,26 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
537
544
  }
538
545
 
539
546
  if (candidate?.finishReason) {
540
- output.stopReason = mapStopReasonString(candidate.finishReason);
541
- if (output.content.some(b => b.type === "toolCall")) {
542
- output.stopReason = "toolUse";
547
+ if (isGoogleCandidateSafetyStopReason(candidate.finishReason)) {
548
+ hasContent = true;
549
+ output.errorKind = PROVIDER_SAFETY_STOP;
550
+ output.stopReason = "error";
551
+ } else if (output.errorKind !== PROVIDER_SAFETY_STOP) {
552
+ output.stopReason = mapStopReasonString(candidate.finishReason);
553
+ if (output.stopReason === "stop" && output.content.some(b => b.type === "toolCall")) {
554
+ output.stopReason = "toolUse";
555
+ }
556
+ }
557
+ }
558
+
559
+ const blockReason = getGooglePromptBlockReason(responseData.promptFeedback);
560
+ if (blockReason) {
561
+ hasContent = true;
562
+ if (isGooglePromptSafetyStopReason(blockReason)) {
563
+ output.errorKind = PROVIDER_SAFETY_STOP;
564
+ output.stopReason = "error";
565
+ } else if (output.errorKind !== PROVIDER_SAFETY_STOP) {
566
+ output.stopReason = "error";
543
567
  }
544
568
  }
545
569
 
@@ -51,6 +51,43 @@ export type {
51
51
  export { normalizeSchemaForGoogle };
52
52
 
53
53
  type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex";
54
+ export const PROVIDER_SAFETY_STOP = "provider_safety_stop";
55
+
56
+ export function isGoogleCandidateSafetyStopReason(reason: string): boolean {
57
+ switch (reason) {
58
+ case "SAFETY":
59
+ case "IMAGE_SAFETY":
60
+ case "PROHIBITED_CONTENT":
61
+ case "IMAGE_PROHIBITED_CONTENT":
62
+ case "SPII":
63
+ case "BLOCKLIST":
64
+ case "RECITATION":
65
+ case "IMAGE_RECITATION":
66
+ case "MODEL_ARMOR":
67
+ return true;
68
+ default:
69
+ return false;
70
+ }
71
+ }
72
+
73
+ export function isGooglePromptSafetyStopReason(reason: string): boolean {
74
+ switch (reason) {
75
+ case "SAFETY":
76
+ case "IMAGE_SAFETY":
77
+ case "PROHIBITED_CONTENT":
78
+ case "BLOCKLIST":
79
+ case "MODEL_ARMOR":
80
+ case "JAILBREAK":
81
+ return true;
82
+ default:
83
+ return false;
84
+ }
85
+ }
86
+
87
+ export function getGooglePromptBlockReason(promptFeedback: { blockReason?: unknown } | undefined): string | undefined {
88
+ const blockReason = promptFeedback?.blockReason;
89
+ return typeof blockReason === "string" && blockReason.length > 0 ? blockReason : undefined;
90
+ }
54
91
 
55
92
  /**
56
93
  * Thinking level for Gemini 3 models. Mirrors Google's `ThinkingLevel` enum values.
@@ -607,9 +644,24 @@ export async function consumeGoogleStream<T extends GoogleApiType>(args: {
607
644
  }
608
645
 
609
646
  if (candidate?.finishReason) {
610
- output.stopReason = mapStopReason(candidate.finishReason);
611
- if (output.content.some(b => b.type === "toolCall")) {
612
- output.stopReason = "toolUse";
647
+ if (isGoogleCandidateSafetyStopReason(candidate.finishReason)) {
648
+ output.errorKind = PROVIDER_SAFETY_STOP;
649
+ output.stopReason = "error";
650
+ } else if (output.errorKind !== PROVIDER_SAFETY_STOP) {
651
+ output.stopReason = mapStopReason(candidate.finishReason);
652
+ if (output.stopReason === "stop" && output.content.some(b => b.type === "toolCall")) {
653
+ output.stopReason = "toolUse";
654
+ }
655
+ }
656
+ }
657
+
658
+ const blockReason = getGooglePromptBlockReason(chunk.promptFeedback);
659
+ if (blockReason) {
660
+ if (isGooglePromptSafetyStopReason(blockReason)) {
661
+ output.errorKind = PROVIDER_SAFETY_STOP;
662
+ output.stopReason = "error";
663
+ } else if (output.errorKind !== PROVIDER_SAFETY_STOP) {
664
+ output.stopReason = "error";
613
665
  }
614
666
  }
615
667
 
@@ -332,6 +332,19 @@ function isCompiledGrammarTooLargeStrictError(
332
332
  /too large/i.test(messageParts)
333
333
  );
334
334
  }
335
+ function hasContentFilterSafetyCode(capturedErrorResponse: CapturedHttpErrorResponse | undefined): boolean {
336
+ const bodyJson = capturedErrorResponse?.bodyJson;
337
+ if (typeof bodyJson !== "object" || bodyJson === null || Array.isArray(bodyJson)) return false;
338
+ if (Reflect.get(bodyJson, "code") === "content_filter") return true;
339
+
340
+ const error = Reflect.get(bodyJson, "error");
341
+ return (
342
+ typeof error === "object" &&
343
+ error !== null &&
344
+ !Array.isArray(error) &&
345
+ Reflect.get(error, "code") === "content_filter"
346
+ );
347
+ }
335
348
 
336
349
  // LIMITATION: The think tag parser uses naive string matching for <think>/<thinking> tags.
337
350
  // If MiniMax models output these literal strings in code blocks, XML examples, or explanations,
@@ -720,6 +733,13 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
720
733
  const calls = kimiHealer.drainCompleted();
721
734
  for (const call of calls) emitHealedToolCall(call);
722
735
  };
736
+ let providerSafetyStop = false;
737
+ const markProviderSafetyStop = (errorMessage?: string): void => {
738
+ providerSafetyStop = true;
739
+ output.errorKind = "provider_safety_stop";
740
+ output.stopReason = "error";
741
+ if (errorMessage) output.errorMessage = errorMessage;
742
+ };
723
743
 
724
744
  for await (const chunk of iterateWithIdleTimeout(openaiStream, {
725
745
  watchdog: firstEventWatchdog,
@@ -751,13 +771,23 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
751
771
 
752
772
  if (choice.finish_reason) {
753
773
  const finishReasonResult = mapStopReason(choice.finish_reason);
754
- output.stopReason = finishReasonResult.stopReason;
755
- if (finishReasonResult.errorMessage) {
756
- output.errorMessage = finishReasonResult.errorMessage;
774
+ if (choice.finish_reason === "content_filter") {
775
+ markProviderSafetyStop(finishReasonResult.errorMessage);
776
+ } else if (!providerSafetyStop) {
777
+ output.stopReason = finishReasonResult.stopReason;
778
+ if (finishReasonResult.errorMessage) {
779
+ output.errorMessage = finishReasonResult.errorMessage;
780
+ }
757
781
  }
758
782
  }
759
783
 
760
784
  if (choice.delta) {
785
+ if (typeof choice.delta.refusal === "string" && choice.delta.refusal.length > 0) {
786
+ appendTextDelta(choice.delta.refusal);
787
+ if (!providerSafetyStop) {
788
+ markProviderSafetyStop("Provider returned a safety refusal");
789
+ }
790
+ }
761
791
  const normalizedDeltaText = normalizeStreamingContentText(choice.delta.content);
762
792
  if (normalizedDeltaText.length > 0) {
763
793
  if (!firstTokenTime) firstTokenTime = Date.now();
@@ -927,15 +957,19 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
927
957
  } catch (error) {
928
958
  for (const block of output.content) delete (block as any).index;
929
959
  const firstEventTimeoutError = abortTracker.getLocalAbortReason();
960
+ const capturedErrorResponse = getCapturedErrorResponse?.();
930
961
  output.stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error";
931
- output.errorStatus = extractHttpStatusFromError(error) ?? getCapturedErrorResponse?.()?.status;
962
+ output.errorStatus = extractHttpStatusFromError(error) ?? capturedErrorResponse?.status;
932
963
  output.errorMessage =
933
964
  firstEventTimeoutError?.message ??
934
- (await finalizeErrorMessage(error, rawRequestDump, getCapturedErrorResponse?.()));
965
+ (await finalizeErrorMessage(error, rawRequestDump, capturedErrorResponse));
935
966
  // Some providers via OpenRouter include extra details here.
936
967
  const rawMetadata = (error as { error?: { metadata?: { raw?: string } } })?.error?.metadata?.raw;
937
968
  if (rawMetadata) output.errorMessage += `\n${rawMetadata}`;
938
969
  output.errorMessage = rewriteCopilotError(output.errorMessage, error, model.provider);
970
+ if (hasContentFilterSafetyCode(capturedErrorResponse)) {
971
+ output.errorKind = "provider_safety_stop";
972
+ }
939
973
  output.duration = Date.now() - startTime;
940
974
  if (firstTokenTime) output.ttft = firstTokenTime - startTime;
941
975
  stream.push({ type: "error", reason: output.stopReason, error: output });
package/src/stream.ts CHANGED
@@ -11,6 +11,11 @@ import {
11
11
  } from "./model-thinking";
12
12
  import type { BedrockOptions } from "./providers/amazon-bedrock";
13
13
  import type { AnthropicOptions } from "./providers/anthropic";
14
+ import {
15
+ hasResolvableAwsProfileSource,
16
+ isValidBedrockBearerToken,
17
+ readAwsStaticEnvironmentCredentials,
18
+ } from "./providers/aws-credential-config";
14
19
  import type { CursorOptions } from "./providers/cursor";
15
20
  import type { GoogleOptions } from "./providers/google";
16
21
  import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli";
@@ -121,28 +126,12 @@ const serviceProviderMap: Record<string, KeyResolver> = {
121
126
  return "<authenticated>";
122
127
  }
123
128
  },
124
- // Amazon Bedrock supports multiple credential sources:
125
- // 1. AWS_PROFILE - named profile from ~/.aws/credentials
126
- // 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY - standard IAM keys
127
- // 3. AWS_BEARER_TOKEN_BEDROCK - Bedrock API keys (bearer token)
128
- // 4. AWS_CONTAINER_CREDENTIALS_* - ECS/Task IAM role credentials
129
- // 5. AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ROLE_ARN - IRSA (EKS) web identity
129
+ // Advertise only credential sources implemented by the Bedrock request path.
130
+ // ECS and IRSA remain unavailable until matching resolvers are implemented.
130
131
  "amazon-bedrock": () => {
131
- const awsProfile = $credentialEnv("AWS_PROFILE");
132
- const awsAccessKeyId = $credentialEnv("AWS_ACCESS_KEY_ID");
133
- const awsSecretAccessKey = $credentialEnv("AWS_SECRET_ACCESS_KEY");
134
- const awsBearerToken = $credentialEnv("AWS_BEARER_TOKEN_BEDROCK");
135
- const hasEcsCredentials =
136
- !!$credentialEnv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") ||
137
- !!$credentialEnv("AWS_CONTAINER_CREDENTIALS_FULL_URI");
138
- const hasWebIdentity = !!$credentialEnv("AWS_WEB_IDENTITY_TOKEN_FILE") && !!$credentialEnv("AWS_ROLE_ARN");
139
- if (
140
- awsProfile ||
141
- (awsAccessKeyId && awsSecretAccessKey) ||
142
- awsBearerToken ||
143
- hasEcsCredentials ||
144
- hasWebIdentity
145
- ) {
132
+ const bearerToken = $credentialEnv("AWS_BEARER_TOKEN_BEDROCK");
133
+ if (bearerToken) return isValidBedrockBearerToken(bearerToken) ? "<authenticated>" : undefined;
134
+ if (readAwsStaticEnvironmentCredentials() || hasResolvableAwsProfileSource()) {
146
135
  return "<authenticated>";
147
136
  }
148
137
  },
package/src/types.ts CHANGED
@@ -552,6 +552,7 @@ export interface Usage {
552
552
  }
553
553
 
554
554
  export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted";
555
+ export type AssistantErrorKind = "provider_safety_stop";
555
556
 
556
557
  export interface OpenAIResponsesHistoryPayload {
557
558
  type: "openaiResponsesHistory";
@@ -594,6 +595,7 @@ export interface AssistantMessage {
594
595
  usage: Usage;
595
596
  stopReason: StopReason;
596
597
  errorMessage?: string;
598
+ errorKind?: AssistantErrorKind;
597
599
  /** HTTP status surfaced by the provider when the request failed. Populated by every provider's catch block alongside `errorMessage` so consumers (auth retry, telemetry, UI) can branch without regex-scraping the message. */
598
600
  errorStatus?: number;
599
601
  /**