@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 +6 -0
- package/dist/types/providers/aws-credential-config.d.ts +19 -0
- package/dist/types/providers/google-shared.d.ts +6 -0
- package/dist/types/types.d.ts +2 -0
- package/package.json +3 -3
- package/src/providers/amazon-bedrock.ts +37 -33
- package/src/providers/anthropic.ts +54 -49
- package/src/providers/aws-credential-config.ts +180 -0
- package/src/providers/aws-credentials.ts +20 -63
- package/src/providers/google-gemini-cli.ts +27 -3
- package/src/providers/google-shared.ts +55 -3
- package/src/providers/openai-completions.ts +39 -5
- package/src/stream.ts +10 -21
- package/src/types.ts +2 -0
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
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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.
|
|
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": "
|
|
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.
|
|
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
|
-
|
|
237
|
-
if (
|
|
238
|
-
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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:
|
|
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
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
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
|
|
1560
|
-
//
|
|
1561
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
output.
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
output.stopReason = "
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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) ??
|
|
962
|
+
output.errorStatus = extractHttpStatusFromError(error) ?? capturedErrorResponse?.status;
|
|
932
963
|
output.errorMessage =
|
|
933
964
|
firstEventTimeoutError?.message ??
|
|
934
|
-
(await finalizeErrorMessage(error, rawRequestDump,
|
|
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
|
-
//
|
|
125
|
-
//
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
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
|
/**
|