@sayknow-cli/ai 0.3.12 → 0.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 "@sayknow-cli/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
 
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
  /**