mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-15 19:40:45 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
9
openclaw/extensions/amazon-bedrock-mantle/api.ts
Normal file
9
openclaw/extensions/amazon-bedrock-mantle/api.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export {
|
||||
discoverMantleModels,
|
||||
generateBearerTokenFromIam,
|
||||
mergeImplicitMantleProvider,
|
||||
resetIamTokenCacheForTest,
|
||||
resetMantleDiscoveryCacheForTest,
|
||||
resolveImplicitMantleProvider,
|
||||
resolveMantleBearerToken,
|
||||
} from "./discovery.js";
|
||||
6
openclaw/extensions/amazon-bedrock-mantle/bedrock-token-generator.d.ts
vendored
Normal file
6
openclaw/extensions/amazon-bedrock-mantle/bedrock-token-generator.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
declare module "@aws/bedrock-token-generator" {
|
||||
export function getTokenProvider(opts?: {
|
||||
region?: string;
|
||||
expiresInSeconds?: number;
|
||||
}): () => Promise<string>;
|
||||
}
|
||||
498
openclaw/extensions/amazon-bedrock-mantle/discovery.test.ts
Normal file
498
openclaw/extensions/amazon-bedrock-mantle/discovery.test.ts
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
discoverMantleModels,
|
||||
generateBearerTokenFromIam,
|
||||
mergeImplicitMantleProvider,
|
||||
resetIamTokenCacheForTest,
|
||||
resetMantleDiscoveryCacheForTest,
|
||||
resolveMantleBearerToken,
|
||||
resolveImplicitMantleProvider,
|
||||
} from "./api.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getTokenProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@aws/bedrock-token-generator", () => ({
|
||||
getTokenProvider: mocks.getTokenProvider,
|
||||
}));
|
||||
|
||||
describe("bedrock mantle discovery", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.restoreAllMocks();
|
||||
mocks.getTokenProvider.mockReset();
|
||||
resetMantleDiscoveryCacheForTest();
|
||||
resetIamTokenCacheForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bearer token resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("resolves bearer token from AWS_BEARER_TOKEN_BEDROCK", () => {
|
||||
expect(
|
||||
resolveMantleBearerToken({
|
||||
AWS_BEARER_TOKEN_BEDROCK: "bedrock-api-key-abc123", // pragma: allowlist secret
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe("bedrock-api-key-abc123");
|
||||
});
|
||||
|
||||
it("returns undefined when no bearer token env var is set", () => {
|
||||
expect(resolveMantleBearerToken({} as NodeJS.ProcessEnv)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("trims whitespace from bearer token", () => {
|
||||
expect(
|
||||
resolveMantleBearerToken({
|
||||
AWS_BEARER_TOKEN_BEDROCK: " my-token ", // pragma: allowlist secret
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe("my-token");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IAM token generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("generates token from IAM credentials when token generation succeeds", async () => {
|
||||
const tokenProvider = vi.fn(async () => "bedrock-api-key-generated"); // pragma: allowlist secret
|
||||
mocks.getTokenProvider.mockReturnValue(tokenProvider);
|
||||
|
||||
const token = await generateBearerTokenFromIam({ region: "us-east-1" });
|
||||
|
||||
expect(token).toBe("bedrock-api-key-generated");
|
||||
expect(mocks.getTokenProvider).toHaveBeenCalledWith({
|
||||
region: "us-east-1",
|
||||
expiresInSeconds: 7200,
|
||||
});
|
||||
expect(tokenProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("caches generated IAM tokens within TTL", async () => {
|
||||
const tokenProvider = vi.fn(async () => "bedrock-api-key-cached"); // pragma: allowlist secret
|
||||
mocks.getTokenProvider.mockReturnValue(tokenProvider);
|
||||
let now = 1000;
|
||||
|
||||
const t1 = await generateBearerTokenFromIam({ region: "us-east-1", now: () => now });
|
||||
now += 1800_000; // 30 min — within 1hr cache TTL
|
||||
const t2 = await generateBearerTokenFromIam({ region: "us-east-1", now: () => now });
|
||||
|
||||
expect(t1).toEqual(t2);
|
||||
expect(tokenProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not reuse an IAM token across regions", async () => {
|
||||
const tokenProvider = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockResolvedValueOnce("bedrock-api-key-east") // pragma: allowlist secret
|
||||
.mockResolvedValueOnce("bedrock-api-key-west"); // pragma: allowlist secret
|
||||
mocks.getTokenProvider.mockReturnValue(tokenProvider);
|
||||
|
||||
const east = await generateBearerTokenFromIam({ region: "us-east-1", now: () => 1000 });
|
||||
const west = await generateBearerTokenFromIam({ region: "us-west-2", now: () => 2000 });
|
||||
|
||||
expect(east).toBe("bedrock-api-key-east");
|
||||
expect(west).toBe("bedrock-api-key-west");
|
||||
expect(mocks.getTokenProvider).toHaveBeenNthCalledWith(1, {
|
||||
region: "us-east-1",
|
||||
expiresInSeconds: 7200,
|
||||
});
|
||||
expect(mocks.getTokenProvider).toHaveBeenNthCalledWith(2, {
|
||||
region: "us-west-2",
|
||||
expiresInSeconds: 7200,
|
||||
});
|
||||
expect(tokenProvider).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns undefined when IAM token generation fails", async () => {
|
||||
mocks.getTokenProvider.mockImplementation(() => {
|
||||
throw new Error("no credentials");
|
||||
});
|
||||
|
||||
await expect(generateBearerTokenFromIam({ region: "us-east-1" })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("discovers models from Mantle /v1/models endpoint sorted by id", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: "openai.gpt-oss-120b", object: "model", owned_by: "openai" },
|
||||
{ id: "anthropic.claude-sonnet-4-6", object: "model", owned_by: "anthropic" },
|
||||
{ id: "mistral.devstral-2-123b", object: "model", owned_by: "mistral" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const models = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(models).toHaveLength(3);
|
||||
// Models should be sorted alphabetically by id
|
||||
expect(models[0]).toMatchObject({
|
||||
id: "anthropic.claude-sonnet-4-6",
|
||||
name: "anthropic.claude-sonnet-4-6",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
});
|
||||
expect(models[1]).toMatchObject({
|
||||
id: "mistral.devstral-2-123b",
|
||||
reasoning: false,
|
||||
});
|
||||
expect(models[2]).toMatchObject({
|
||||
id: "openai.gpt-oss-120b",
|
||||
reasoning: true, // GPT-OSS 120B supports reasoning
|
||||
});
|
||||
|
||||
// Verify correct endpoint and auth header
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://bedrock-mantle.us-east-1.api.aws/v1/models",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer test-token",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("infers reasoning support from model IDs", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: "moonshotai.kimi-k2-thinking", object: "model" },
|
||||
{ id: "openai.gpt-oss-120b", object: "model" },
|
||||
{ id: "openai.gpt-oss-safeguard-120b", object: "model" },
|
||||
{ id: "deepseek.v3.2", object: "model" },
|
||||
{ id: "mistral.mistral-large-3-675b-instruct", object: "model" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const models = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
const byId = Object.fromEntries(models.map((m) => [m.id, m]));
|
||||
expect(byId["moonshotai.kimi-k2-thinking"]?.reasoning).toBe(true);
|
||||
expect(byId["openai.gpt-oss-120b"]?.reasoning).toBe(true);
|
||||
expect(byId["openai.gpt-oss-safeguard-120b"]?.reasoning).toBe(true);
|
||||
expect(byId["deepseek.v3.2"]?.reasoning).toBe(false);
|
||||
expect(byId["mistral.mistral-large-3-675b-instruct"]?.reasoning).toBe(false);
|
||||
});
|
||||
|
||||
it("returns empty array on permission error", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: "Forbidden",
|
||||
});
|
||||
|
||||
const models = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array on network error", async () => {
|
||||
const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
|
||||
const models = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters out models with empty IDs", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: "anthropic.claude-sonnet-4-6", object: "model" },
|
||||
{ id: "", object: "model" },
|
||||
{ id: " ", object: "model" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const models = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(models).toHaveLength(1);
|
||||
expect(models[0]?.id).toBe("anthropic.claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discovery caching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("returns cached models on subsequent calls within refresh interval", async () => {
|
||||
let now = 1000000;
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ id: "anthropic.claude-sonnet-4-6", object: "model" }],
|
||||
}),
|
||||
});
|
||||
|
||||
// First call — hits the network
|
||||
const first = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
now: () => now,
|
||||
});
|
||||
expect(first).toHaveLength(1);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call within refresh interval — uses cache
|
||||
now += 60_000; // 1 minute later
|
||||
const second = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
now: () => now,
|
||||
});
|
||||
expect(second).toHaveLength(1);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1); // No additional fetch
|
||||
|
||||
// Third call after refresh interval — re-fetches
|
||||
now += 3600_000; // 1 hour later
|
||||
const third = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
now: () => now,
|
||||
});
|
||||
expect(third).toHaveLength(1);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2); // Re-fetched
|
||||
});
|
||||
|
||||
it("returns stale cache on fetch failure", async () => {
|
||||
let now = 1000000;
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ id: "anthropic.claude-sonnet-4-6", object: "model" }],
|
||||
}),
|
||||
})
|
||||
.mockRejectedValueOnce(new Error("ECONNREFUSED"));
|
||||
|
||||
// First call — succeeds
|
||||
await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
// Second call after expiry — fails but returns stale cache
|
||||
now += 7200_000;
|
||||
const stale = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
now: () => now,
|
||||
});
|
||||
expect(stale).toHaveLength(1);
|
||||
expect(stale[0]?.id).toBe("anthropic.claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implicit provider resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("resolves implicit provider when bearer token is set", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ id: "anthropic.claude-sonnet-4-6", object: "model" }],
|
||||
}),
|
||||
});
|
||||
|
||||
const provider = await resolveImplicitMantleProvider({
|
||||
env: {
|
||||
AWS_BEARER_TOKEN_BEDROCK: "my-token", // pragma: allowlist secret
|
||||
AWS_REGION: "us-east-1",
|
||||
} as NodeJS.ProcessEnv,
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(provider).not.toBeNull();
|
||||
expect(provider?.baseUrl).toBe("https://bedrock-mantle.us-east-1.api.aws/v1");
|
||||
expect(provider?.api).toBe("openai-completions");
|
||||
expect(provider?.auth).toBe("api-key");
|
||||
expect(provider?.apiKey).toBe("env:AWS_BEARER_TOKEN_BEDROCK");
|
||||
expect(provider?.models).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns null when no auth is available", async () => {
|
||||
mocks.getTokenProvider.mockImplementation(() => {
|
||||
throw new Error("no credentials");
|
||||
});
|
||||
|
||||
const provider = await resolveImplicitMantleProvider({
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(provider).toBeNull();
|
||||
});
|
||||
|
||||
it("uses a generated IAM token when no explicit token is set", async () => {
|
||||
const tokenProvider = vi.fn(async () => "bedrock-api-key-iam"); // pragma: allowlist secret
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ id: "openai.gpt-oss-120b", object: "model" }],
|
||||
}),
|
||||
});
|
||||
mocks.getTokenProvider.mockReturnValue(tokenProvider);
|
||||
|
||||
const provider = await resolveImplicitMantleProvider({
|
||||
env: {
|
||||
AWS_PROFILE: "default",
|
||||
AWS_REGION: "us-east-1",
|
||||
} as NodeJS.ProcessEnv,
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(provider).not.toBeNull();
|
||||
expect(provider?.apiKey).toBe("bedrock-api-key-iam");
|
||||
expect(tokenProvider).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://bedrock-mantle.us-east-1.api.aws/v1/models",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer bedrock-api-key-iam",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for unsupported regions", async () => {
|
||||
const provider = await resolveImplicitMantleProvider({
|
||||
env: {
|
||||
AWS_BEARER_TOKEN_BEDROCK: "my-token", // pragma: allowlist secret
|
||||
AWS_REGION: "af-south-1",
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(provider).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults to us-east-1 when no region is set", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: "openai.gpt-oss-120b", object: "model" }] }),
|
||||
});
|
||||
|
||||
const provider = await resolveImplicitMantleProvider({
|
||||
env: {
|
||||
AWS_BEARER_TOKEN_BEDROCK: "my-token", // pragma: allowlist secret
|
||||
} as NodeJS.ProcessEnv,
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(provider?.baseUrl).toBe("https://bedrock-mantle.us-east-1.api.aws/v1");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://bedrock-mantle.us-east-1.api.aws/v1/models",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider merging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("merges implicit models when existing provider has empty models", () => {
|
||||
const result = mergeImplicitMantleProvider({
|
||||
existing: {
|
||||
baseUrl: "https://custom.example.com/v1",
|
||||
models: [],
|
||||
},
|
||||
implicit: {
|
||||
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1",
|
||||
api: "openai-completions",
|
||||
auth: "api-key",
|
||||
apiKey: "env:AWS_BEARER_TOKEN_BEDROCK",
|
||||
models: [
|
||||
{
|
||||
id: "openai.gpt-oss-120b",
|
||||
name: "GPT-OSS 120B",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 32000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.baseUrl).toBe("https://custom.example.com/v1");
|
||||
expect(result.models?.map((m) => m.id)).toEqual(["openai.gpt-oss-120b"]);
|
||||
});
|
||||
|
||||
it("preserves existing models over implicit ones", () => {
|
||||
const result = mergeImplicitMantleProvider({
|
||||
existing: {
|
||||
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1",
|
||||
models: [
|
||||
{
|
||||
id: "custom-model",
|
||||
name: "My Custom Model",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 64000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
},
|
||||
implicit: {
|
||||
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1",
|
||||
api: "openai-completions",
|
||||
auth: "api-key",
|
||||
models: [
|
||||
{
|
||||
id: "openai.gpt-oss-120b",
|
||||
name: "GPT-OSS 120B",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 32000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.models?.map((m) => m.id)).toEqual(["custom-model"]);
|
||||
});
|
||||
});
|
||||
314
openclaw/extensions/amazon-bedrock-mantle/discovery.ts
Normal file
314
openclaw/extensions/amazon-bedrock-mantle/discovery.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type {
|
||||
ModelDefinitionConfig,
|
||||
ModelProviderConfig,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
const log = createSubsystemLogger("bedrock-mantle-discovery");
|
||||
|
||||
const DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const DEFAULT_CONTEXT_WINDOW = 32000;
|
||||
const DEFAULT_MAX_TOKENS = 4096;
|
||||
const DEFAULT_REFRESH_INTERVAL_SECONDS = 3600; // 1 hour
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mantle region & endpoint helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MANTLE_SUPPORTED_REGIONS = [
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-2",
|
||||
"ap-northeast-1",
|
||||
"ap-south-1",
|
||||
"ap-southeast-3",
|
||||
"eu-central-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-south-1",
|
||||
"eu-north-1",
|
||||
"sa-east-1",
|
||||
] as const;
|
||||
|
||||
function mantleEndpoint(region: string): string {
|
||||
return `https://bedrock-mantle.${region}.api.aws`;
|
||||
}
|
||||
|
||||
function isSupportedRegion(region: string): boolean {
|
||||
return (MANTLE_SUPPORTED_REGIONS as readonly string[]).includes(region);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bearer token resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MantleBearerTokenProvider = () => Promise<string>;
|
||||
|
||||
/**
|
||||
* Resolve a bearer token for Mantle authentication.
|
||||
*
|
||||
* Returns the value of AWS_BEARER_TOKEN_BEDROCK if set, undefined otherwise.
|
||||
* When no explicit token is set, `resolveImplicitMantleProvider` will attempt
|
||||
* to generate one from IAM credentials via `@aws/bedrock-token-generator`.
|
||||
*/
|
||||
export function resolveMantleBearerToken(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
||||
const explicitToken = env.AWS_BEARER_TOKEN_BEDROCK?.trim();
|
||||
if (explicitToken) {
|
||||
return explicitToken;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Token cache for IAM-derived bearer tokens, keyed by region. */
|
||||
const iamTokenCache = new Map<string, { token: string; expiresAt: number }>();
|
||||
const IAM_TOKEN_TTL_MS = 3600_000; // Refresh every 1 hour (tokens valid up to 12h)
|
||||
|
||||
/**
|
||||
* Generate a bearer token from IAM credentials using `@aws/bedrock-token-generator`.
|
||||
*
|
||||
* Uses the AWS default credential chain (instance roles, SSO, access keys, EKS IRSA).
|
||||
* Returns undefined if the package is not installed or credentials are unavailable.
|
||||
*/
|
||||
export async function generateBearerTokenFromIam(params: {
|
||||
region: string;
|
||||
now?: () => number;
|
||||
}): Promise<string | undefined> {
|
||||
const now = params.now?.() ?? Date.now();
|
||||
const cached = iamTokenCache.get(params.region);
|
||||
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
try {
|
||||
const { getTokenProvider } = (await import("@aws/bedrock-token-generator")) as {
|
||||
getTokenProvider: (opts?: {
|
||||
region?: string;
|
||||
expiresInSeconds?: number;
|
||||
}) => () => Promise<string>;
|
||||
};
|
||||
const token = await getTokenProvider({
|
||||
region: params.region,
|
||||
expiresInSeconds: 7200, // 2 hours
|
||||
})();
|
||||
iamTokenCache.set(params.region, { token, expiresAt: now + IAM_TOKEN_TTL_MS });
|
||||
return token;
|
||||
} catch (error) {
|
||||
log.debug?.("Mantle IAM token generation unavailable", {
|
||||
region: params.region,
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset the IAM token cache (for testing). */
|
||||
export function resetIamTokenCacheForTest(): void {
|
||||
iamTokenCache.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenAI-format model list response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface OpenAIModelEntry {
|
||||
id: string;
|
||||
object?: string;
|
||||
owned_by?: string;
|
||||
created?: number;
|
||||
}
|
||||
|
||||
interface OpenAIModelsResponse {
|
||||
data?: OpenAIModelEntry[];
|
||||
object?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reasoning heuristic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Model ID substrings that indicate reasoning/thinking support. */
|
||||
const REASONING_PATTERNS = [
|
||||
"thinking",
|
||||
"reasoner",
|
||||
"reasoning",
|
||||
"deepseek.r",
|
||||
"gpt-oss-120b", // GPT-OSS 120B supports reasoning
|
||||
"gpt-oss-safeguard-120b",
|
||||
];
|
||||
|
||||
function inferReasoningSupport(modelId: string): boolean {
|
||||
const lower = normalizeLowercaseStringOrEmpty(modelId);
|
||||
return REASONING_PATTERNS.some((p) => lower.includes(p));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discovery cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MantleCacheEntry {
|
||||
models: ModelDefinitionConfig[];
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
const discoveryCache = new Map<string, MantleCacheEntry>();
|
||||
|
||||
/** Clear the discovery cache (for testing). */
|
||||
export function resetMantleDiscoveryCacheForTest(): void {
|
||||
discoveryCache.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Discover available models from the Mantle `/v1/models` endpoint.
|
||||
*
|
||||
* The response is in standard OpenAI format:
|
||||
* ```json
|
||||
* { "data": [{ "id": "anthropic.claude-sonnet-4-6", "object": "model", "owned_by": "anthropic" }] }
|
||||
* ```
|
||||
*
|
||||
* Results are cached per region for `DEFAULT_REFRESH_INTERVAL_SECONDS`.
|
||||
* Returns an empty array if the request fails (no permission, network error, etc.).
|
||||
*/
|
||||
export async function discoverMantleModels(params: {
|
||||
region: string;
|
||||
bearerToken: string;
|
||||
fetchFn?: typeof fetch;
|
||||
now?: () => number;
|
||||
}): Promise<ModelDefinitionConfig[]> {
|
||||
const { region, bearerToken, fetchFn = fetch, now = Date.now } = params;
|
||||
|
||||
// Check cache
|
||||
const cacheKey = region;
|
||||
const cached = discoveryCache.get(cacheKey);
|
||||
if (cached && now() - cached.fetchedAt < DEFAULT_REFRESH_INTERVAL_SECONDS * 1000) {
|
||||
return cached.models;
|
||||
}
|
||||
|
||||
const endpoint = `${mantleEndpoint(region)}/v1/models`;
|
||||
|
||||
try {
|
||||
const response = await fetchFn(endpoint, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearerToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
log.debug?.("Mantle model discovery failed", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
return cached?.models ?? [];
|
||||
}
|
||||
|
||||
const body = (await response.json()) as OpenAIModelsResponse;
|
||||
const rawModels = body.data ?? [];
|
||||
|
||||
const models = rawModels
|
||||
.filter((m) => m.id?.trim())
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.id, // Mantle doesn't return display names
|
||||
reasoning: inferReasoningSupport(m.id),
|
||||
input: ["text" as const],
|
||||
cost: DEFAULT_COST,
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_MAX_TOKENS,
|
||||
}))
|
||||
.toSorted((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
discoveryCache.set(cacheKey, { models, fetchedAt: now() });
|
||||
return models;
|
||||
} catch (error) {
|
||||
log.debug?.("Mantle model discovery error", {
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
return cached?.models ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implicit provider resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve an implicit Bedrock Mantle provider if authentication is available.
|
||||
*
|
||||
* Detection priority:
|
||||
* 1. AWS_BEARER_TOKEN_BEDROCK env var → use directly
|
||||
* 2. IAM credentials → generate bearer token via `@aws/bedrock-token-generator`
|
||||
* - Region from AWS_REGION / AWS_DEFAULT_REGION / default us-east-1
|
||||
* - Models discovered from `/v1/models`
|
||||
*/
|
||||
export async function resolveImplicitMantleProvider(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<ModelProviderConfig | null> {
|
||||
const env = params.env ?? process.env;
|
||||
const region = env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
|
||||
const explicitBearerToken = resolveMantleBearerToken(env);
|
||||
|
||||
if (!isSupportedRegion(region)) {
|
||||
log.debug?.("Mantle not available in region", { region });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try explicit token first, then generate from IAM credentials
|
||||
const bearerToken = explicitBearerToken ?? (await generateBearerTokenFromIam({ region }));
|
||||
|
||||
if (!bearerToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const models = await discoverMantleModels({
|
||||
region,
|
||||
bearerToken,
|
||||
fetchFn: params.fetchFn,
|
||||
});
|
||||
|
||||
if (models.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
log.debug?.("Mantle provider resolved", { region, modelCount: models.length });
|
||||
|
||||
return {
|
||||
baseUrl: `${mantleEndpoint(region)}/v1`,
|
||||
api: "openai-completions",
|
||||
auth: "api-key",
|
||||
apiKey: explicitBearerToken ? "env:AWS_BEARER_TOKEN_BEDROCK" : bearerToken,
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeImplicitMantleProvider(params: {
|
||||
existing: ModelProviderConfig | undefined;
|
||||
implicit: ModelProviderConfig;
|
||||
}): ModelProviderConfig {
|
||||
const { existing, implicit } = params;
|
||||
if (!existing) {
|
||||
return implicit;
|
||||
}
|
||||
return {
|
||||
...implicit,
|
||||
...existing,
|
||||
models:
|
||||
Array.isArray(existing.models) && existing.models.length > 0
|
||||
? existing.models
|
||||
: implicit.models,
|
||||
};
|
||||
}
|
||||
24
openclaw/extensions/amazon-bedrock-mantle/index.test.ts
Normal file
24
openclaw/extensions/amazon-bedrock-mantle/index.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
|
||||
import bedrockMantlePlugin from "./index.js";
|
||||
|
||||
describe("amazon-bedrock-mantle provider plugin", () => {
|
||||
it("registers with correct provider ID and label", async () => {
|
||||
const provider = await registerSingleProviderPlugin(bedrockMantlePlugin);
|
||||
expect(provider.id).toBe("amazon-bedrock-mantle");
|
||||
expect(provider.label).toBe("Amazon Bedrock Mantle (OpenAI-compatible)");
|
||||
});
|
||||
|
||||
it("classifies rate limit errors for failover", async () => {
|
||||
const provider = await registerSingleProviderPlugin(bedrockMantlePlugin);
|
||||
expect(
|
||||
provider.classifyFailoverReason?.({ errorMessage: "rate_limit exceeded" } as never),
|
||||
).toBe("rate_limit");
|
||||
expect(
|
||||
provider.classifyFailoverReason?.({ errorMessage: "429 Too Many Requests" } as never),
|
||||
).toBe("rate_limit");
|
||||
expect(
|
||||
provider.classifyFailoverReason?.({ errorMessage: "some other error" } as never),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
11
openclaw/extensions/amazon-bedrock-mantle/index.ts
Normal file
11
openclaw/extensions/amazon-bedrock-mantle/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { registerBedrockMantlePlugin } from "./register.sync.runtime.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "amazon-bedrock-mantle",
|
||||
name: "Amazon Bedrock Mantle Provider",
|
||||
description: "Bundled Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
|
||||
register(api) {
|
||||
registerBedrockMantlePlugin(api);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "amazon-bedrock-mantle",
|
||||
"enabledByDefault": true,
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
},
|
||||
"providers": ["amazon-bedrock-mantle"]
|
||||
}
|
||||
21
openclaw/extensions/amazon-bedrock-mantle/package.json
Normal file
21
openclaw/extensions/amazon-bedrock-mantle/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.4.20",
|
||||
"private": true,
|
||||
"description": "OpenClaw Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@aws/bedrock-token-generator": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"bundle": {
|
||||
"stageRuntimeDependencies": true
|
||||
},
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
mergeImplicitMantleProvider,
|
||||
resolveImplicitMantleProvider,
|
||||
resolveMantleBearerToken,
|
||||
} from "./discovery.js";
|
||||
|
||||
export function registerBedrockMantlePlugin(api: OpenClawPluginApi): void {
|
||||
const providerId = "amazon-bedrock-mantle";
|
||||
|
||||
api.registerProvider({
|
||||
id: providerId,
|
||||
label: "Amazon Bedrock Mantle (OpenAI-compatible)",
|
||||
docsPath: "/providers/bedrock-mantle",
|
||||
auth: [],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => {
|
||||
const implicit = await resolveImplicitMantleProvider({
|
||||
env: ctx.env,
|
||||
});
|
||||
if (!implicit) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: mergeImplicitMantleProvider({
|
||||
existing: ctx.config.models?.providers?.[providerId],
|
||||
implicit,
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
resolveConfigApiKey: ({ env }) =>
|
||||
resolveMantleBearerToken(env) ? "AWS_BEARER_TOKEN_BEDROCK" : undefined,
|
||||
matchesContextOverflowError: ({ errorMessage }) =>
|
||||
/context_length_exceeded|max.*tokens.*exceeded/i.test(errorMessage),
|
||||
classifyFailoverReason: ({ errorMessage }) => {
|
||||
if (/rate_limit|too many requests|429/i.test(errorMessage)) {
|
||||
return "rate_limit";
|
||||
}
|
||||
if (/overloaded|503/i.test(errorMessage)) {
|
||||
return "overloaded";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
16
openclaw/extensions/amazon-bedrock-mantle/tsconfig.json
Normal file
16
openclaw/extensions/amazon-bedrock-mantle/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue