Skip to content

Commit b59a85f

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/windows-agent-file-case
2 parents 83abf1b + 7e4a76a commit b59a85f

17 files changed

Lines changed: 605 additions & 98 deletions

extensions/amazon-bedrock-mantle/discovery.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,38 @@ describe("bedrock mantle discovery", () => {
710710
objectArgAt(mockFetch, 0, 1);
711711
});
712712

713+
it.each([
714+
{
715+
name: "the fallback region when the primary env is blank",
716+
env: { AWS_REGION: " ", AWS_DEFAULT_REGION: "eu-west-1" },
717+
expectedRegion: "eu-west-1",
718+
},
719+
{
720+
name: "the default region when both env values are blank",
721+
env: { AWS_REGION: "", AWS_DEFAULT_REGION: "\t" },
722+
expectedRegion: "us-east-1",
723+
},
724+
])("uses $name", async ({ env, expectedRegion }) => {
725+
const mockFetch = vi
726+
.fn()
727+
.mockResolvedValue(
728+
modelDiscoveryResponse({ data: [{ id: "openai.gpt-oss-120b", object: "model" }] }),
729+
);
730+
731+
const provider = await resolveImplicitMantleProvider({
732+
env: {
733+
AWS_BEARER_TOKEN_BEDROCK: MANTLE_IAM_TOKEN_MARKER,
734+
...env,
735+
} as NodeJS.ProcessEnv,
736+
fetchFn: mockFetch as unknown as typeof fetch,
737+
});
738+
739+
expect(provider?.baseUrl).toBe(`https://bedrock-mantle.${expectedRegion}.api.aws/v1`);
740+
expect(stringArgAt(mockFetch, 0, 0)).toBe(
741+
`https://bedrock-mantle.${expectedRegion}.api.aws/v1/models`,
742+
);
743+
});
744+
713745
// ---------------------------------------------------------------------------
714746
// Provider merging
715747
// ---------------------------------------------------------------------------

extensions/amazon-bedrock-mantle/discovery.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import type {
1313
ModelProviderConfig,
1414
} from "openclaw/plugin-sdk/provider-model-shared";
1515
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
16-
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
16+
import {
17+
normalizeLowercaseStringOrEmpty,
18+
normalizeOptionalString,
19+
} from "openclaw/plugin-sdk/string-coerce-runtime";
1720

1821
const log = createSubsystemLogger("bedrock-mantle-discovery");
1922

@@ -116,7 +119,11 @@ const iamTokenCache = new Map<string, { token: string; expiresAt: number }>();
116119
const IAM_TOKEN_TTL_MS = 7200_000; // Matches the 2h token lifetime we request below.
117120

118121
function resolveMantleRegion(env: NodeJS.ProcessEnv): string {
119-
return env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
122+
return (
123+
normalizeOptionalString(env.AWS_REGION) ??
124+
normalizeOptionalString(env.AWS_DEFAULT_REGION) ??
125+
"us-east-1"
126+
);
120127
}
121128

122129
function getCachedIamTokenEntry(

extensions/amazon-bedrock/discovery.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,34 @@ describe("bedrock discovery", () => {
795795
expect(sendMock).toHaveBeenCalledTimes(2);
796796
});
797797

798+
it.each([
799+
{
800+
name: "secondary region when the primary env override is blank",
801+
env: { AWS_REGION: " ", AWS_DEFAULT_REGION: "eu-west-1" },
802+
expectedRegion: "eu-west-1",
803+
},
804+
{
805+
name: "plugin default when both region env overrides are blank",
806+
env: { AWS_REGION: "", AWS_DEFAULT_REGION: " " },
807+
expectedRegion: "us-east-1",
808+
},
809+
{
810+
name: "primary region when both env overrides are nonblank",
811+
env: { AWS_REGION: "ap-southeast-2", AWS_DEFAULT_REGION: "eu-west-1" },
812+
expectedRegion: "ap-southeast-2",
813+
},
814+
])("uses $name", async ({ env, expectedRegion }) => {
815+
mockSingleActiveSummary();
816+
817+
const provider = await resolveImplicitBedrockProvider({
818+
pluginConfig: { discovery: { enabled: true } },
819+
env,
820+
clientFactory,
821+
});
822+
823+
expect(provider?.baseUrl).toBe(`https://bedrock-runtime.${expectedRegion}.amazonaws.com`);
824+
});
825+
798826
// Ported from #65449 by @alickgithub2 — extended to also cover apac. prefix
799827
it("resolves au. and apac. prefixes for regional inference profiles", async () => {
800828
sendMock

extensions/amazon-bedrock/discovery.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
import {
2929
normalizeLowercaseStringOrEmpty,
3030
normalizeOptionalLowercaseString,
31+
normalizeOptionalString,
3132
} from "openclaw/plugin-sdk/string-coerce-runtime";
3233
import { refreshAwsSharedConfigCacheForBedrock } from "./aws-credential-refresh.js";
3334
import {
@@ -675,7 +676,11 @@ export async function resolveImplicitBedrockProvider(params: {
675676
return null;
676677
}
677678

678-
const region = discoveryConfig?.region ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
679+
const region =
680+
discoveryConfig?.region ??
681+
normalizeOptionalString(env.AWS_REGION) ??
682+
normalizeOptionalString(env.AWS_DEFAULT_REGION) ??
683+
"us-east-1";
679684
const models = await discoverBedrockModels({
680685
region,
681686
config: discoveryConfig,

extensions/amazon-bedrock/embedding-provider.test.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,45 @@
11
// Amazon Bedrock tests cover embedding provider plugin behavior.
2-
import { describe, expect, it, vi } from "vitest";
3-
import { hasAwsCredentials } from "./embedding-provider.js";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import { createBedrockEmbeddingProvider, hasAwsCredentials } from "./embedding-provider.js";
44
import { embeddingTesting as testing } from "./test-support.js";
55

6+
afterEach(() => {
7+
vi.unstubAllEnvs();
8+
});
9+
10+
describe("bedrock embedding region resolution", () => {
11+
it.each([
12+
{
13+
name: "secondary region when the primary env override is blank",
14+
primary: " ",
15+
secondary: "eu-west-1",
16+
expected: "eu-west-1",
17+
},
18+
{
19+
name: "plugin default when both env overrides are blank",
20+
primary: "",
21+
secondary: " ",
22+
expected: "us-east-1",
23+
},
24+
{
25+
name: "primary region when both env overrides are nonblank",
26+
primary: "ap-southeast-2",
27+
secondary: "eu-west-1",
28+
expected: "ap-southeast-2",
29+
},
30+
])("uses $name", async ({ primary, secondary, expected }) => {
31+
vi.stubEnv("AWS_REGION", primary);
32+
vi.stubEnv("AWS_DEFAULT_REGION", secondary);
33+
34+
const { client } = await createBedrockEmbeddingProvider({
35+
config: {},
36+
model: "",
37+
});
38+
39+
expect(client.region).toBe(expected);
40+
});
41+
});
42+
643
describe("hasAwsCredentials", () => {
744
it("accepts static AWS key credentials without loading the credential chain", async () => {
845
const loadCredentialProvider = vi.fn();

extensions/amazon-bedrock/embedding-provider.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import {
1212
asOptionalRecord as asRecord,
1313
normalizeLowercaseStringOrEmpty,
14+
normalizeOptionalString,
1415
} from "openclaw/plugin-sdk/string-coerce-runtime";
1516
import { refreshAwsSharedConfigCacheForBedrock } from "./aws-credential-refresh.js";
1617

@@ -406,8 +407,8 @@ function resolveBedrockEmbeddingClient(
406407
const region =
407408
regionFromUrl(options.remote?.baseUrl) ??
408409
regionFromUrl(providerConfig?.baseUrl) ??
409-
process.env.AWS_REGION ??
410-
process.env.AWS_DEFAULT_REGION ??
410+
normalizeOptionalString(process.env.AWS_REGION) ??
411+
normalizeOptionalString(process.env.AWS_DEFAULT_REGION) ??
411412
"us-east-1";
412413

413414
let dimensions: number | undefined;

extensions/amazon-bedrock/stream.runtime.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,20 @@ describe("Bedrock profile endpoint resolution", () => {
238238
ambientRegion: "eu-west-1",
239239
expectedRegion: "eu-west-1",
240240
},
241+
{
242+
name: "blank primary region with a fallback env",
243+
modelId: "amazon.nova-micro-v1:0",
244+
ambientRegion: " ",
245+
fallbackRegion: "eu-west-1",
246+
expectedRegion: "eu-west-1",
247+
},
248+
{
249+
name: "blank region env vars",
250+
modelId: "amazon.nova-micro-v1:0",
251+
ambientRegion: " ",
252+
fallbackRegion: "\t",
253+
expectedRegion: "us-east-1",
254+
},
241255
{
242256
name: "application inference-profile ARN",
243257
modelId: "arn:aws:bedrock:us-west-2:123456789012:application-inference-profile/profile-abc",
@@ -260,8 +274,11 @@ describe("Bedrock profile endpoint resolution", () => {
260274
},
261275
])(
262276
"resolves $name to $expectedRegion",
263-
async ({ modelId, ambientRegion, explicitRegion, expectedRegion }) => {
277+
async ({ modelId, ambientRegion, fallbackRegion, explicitRegion, expectedRegion }) => {
264278
vi.stubEnv("AWS_REGION", ambientRegion);
279+
if (fallbackRegion !== undefined) {
280+
vi.stubEnv("AWS_DEFAULT_REGION", fallbackRegion);
281+
}
265282

266283
await expect(
267284
captureClientRegion(

extensions/amazon-bedrock/stream.runtime.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import {
6969
notifyLlmRequestActivity,
7070
} from "openclaw/plugin-sdk/provider-stream-shared";
7171
import { describeToolResultMediaPlaceholder } from "openclaw/plugin-sdk/provider-transport-runtime";
72+
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
7273
import { supportsBedrockPromptCaching, type BedrockOptions } from "./bedrock-options.js";
7374
import { supportsBedrockNativeMaxEffort } from "./thinking-policy.js";
7475

@@ -1038,7 +1039,11 @@ function getConfiguredBedrockRegion(options: BedrockOptions): string | undefined
10381039
return options.region;
10391040
}
10401041

1041-
return options.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || undefined;
1042+
return (
1043+
options.region ||
1044+
normalizeOptionalString(process.env.AWS_REGION) ||
1045+
normalizeOptionalString(process.env.AWS_DEFAULT_REGION)
1046+
);
10421047
}
10431048

10441049
function hasConfiguredBedrockProfile(options: BedrockOptions): boolean {
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Real-behavior proof (real sockets, real undici fetch, real timers): a live HTTP
2+
// endpoint that sends headers and then stalls or slow-drips its body must be bounded
3+
// by the request deadline, not only by the per-chunk idle guard. This exercises the
4+
// production containerRpcRequest -> containerRestRequest -> readSignalRestText path
5+
// without mocking fetch, unlike the fake-timer unit tests.
6+
import http from "node:http";
7+
import type { AddressInfo } from "node:net";
8+
import { afterEach, describe, expect, it } from "vitest";
9+
import { containerRpcRequest } from "./client-container.js";
10+
11+
type StartedServer = { baseUrl: string; close: () => Promise<void> };
12+
13+
const running: StartedServer[] = [];
14+
15+
afterEach(async () => {
16+
while (running.length > 0) {
17+
await running.pop()?.close();
18+
}
19+
});
20+
21+
async function startServer(handler: http.RequestListener): Promise<StartedServer> {
22+
const server = http.createServer(handler);
23+
server.on("clientError", () => {});
24+
await new Promise<void>((resolve) => {
25+
server.listen(0, "127.0.0.1", resolve);
26+
});
27+
const { port } = server.address() as AddressInfo;
28+
const started: StartedServer = {
29+
baseUrl: `http://127.0.0.1:${port}`,
30+
close: () =>
31+
new Promise<void>((resolve) => {
32+
server.closeAllConnections();
33+
server.close(() => resolve());
34+
}),
35+
};
36+
running.push(started);
37+
return started;
38+
}
39+
40+
describe("signal REST real-server deadline", () => {
41+
it("aborts a slow-drip body that never idles, at the request deadline", async () => {
42+
// Drip a byte every 50ms: below the 300ms idle guard, so only the total request
43+
// deadline can stop it. This is the exact slow-drip case the fix bounds.
44+
let dripCount = 0;
45+
const server = await startServer((_req, res) => {
46+
res.writeHead(200, { "content-type": "application/json" });
47+
res.write("{");
48+
const drip = setInterval(() => {
49+
try {
50+
dripCount += 1;
51+
res.write(" ");
52+
} catch {
53+
clearInterval(drip);
54+
}
55+
}, 50);
56+
res.on("close", () => clearInterval(drip));
57+
});
58+
59+
const startedAt = Date.now();
60+
await expect(
61+
containerRpcRequest("version", undefined, { baseUrl: server.baseUrl, timeoutMs: 300 }),
62+
).rejects.toThrow(/Signal REST request timed out|stalled/);
63+
const elapsedMs = Date.now() - startedAt;
64+
65+
// Multiple chunks arrived below the idle threshold, yet the absolute deadline
66+
// still bounded the call. Without it, this response would continue indefinitely.
67+
expect(dripCount).toBeGreaterThan(1);
68+
expect(elapsedMs).toBeLessThan(2_000);
69+
});
70+
71+
it("aborts a response whose body stalls immediately after headers", async () => {
72+
const server = await startServer((_req, res) => {
73+
res.writeHead(200, { "content-type": "application/json" });
74+
res.write("{");
75+
// Never ends the body.
76+
});
77+
78+
const startedAt = Date.now();
79+
await expect(
80+
containerRpcRequest("version", undefined, { baseUrl: server.baseUrl, timeoutMs: 300 }),
81+
).rejects.toThrow(/Signal REST (request timed out|response body stalled)/);
82+
expect(Date.now() - startedAt).toBeLessThan(2_000);
83+
});
84+
85+
it("returns the parsed body when it completes within the deadline", async () => {
86+
const server = await startServer((_req, res) => {
87+
res.writeHead(200, { "content-type": "application/json" });
88+
res.end(JSON.stringify({ versions: ["v1"], build: 2 }));
89+
});
90+
91+
const result = await containerRpcRequest<{ versions?: string[]; build?: number }>(
92+
"version",
93+
undefined,
94+
{ baseUrl: server.baseUrl, timeoutMs: 1_000 },
95+
);
96+
expect(result).toEqual({ versions: ["v1"], build: 2 });
97+
});
98+
});

0 commit comments

Comments
 (0)