Compare commits

...

3 Commits

Author SHA1 Message Date
Peter Steinberger
8df39f19c1 fix: support Bedrock auth + inline provider ids (#1286) (thanks @alauppe) 2026-01-20 07:02:52 +00:00
Andrew Lauppe
9ca5cb2db9 fix(models): attach provider to inline model definitions
When resolving models from custom provider configurations, ensure the
provider name is attached to each inline model entry. This fixes model
resolution for custom providers where the model definition exists in
the config but lacks an explicit provider field.

Without this fix, inline models from custom providers (like amazon-bedrock)
would fail to resolve because the provider context was lost during the
flatMap operation.
2026-01-20 06:51:25 +00:00
Andrew Lauppe
7f25523d89 feat(models): add bedrock-converse-stream API type
Add AWS Bedrock Converse Stream API to the list of supported model APIs,
enabling custom provider configurations for Amazon Bedrock endpoints.

This allows users to configure Bedrock models in their clawdbot.json:

  "models": {
    "providers": {
      "amazon-bedrock": {
        "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com",
        "api": "bedrock-converse-stream",
        "models": [...]
      }
    }
  }

The underlying adapter already exists; this change exposes it as a valid
configuration option.
2026-01-20 06:51:25 +00:00
7 changed files with 128 additions and 6 deletions

View File

@ -11,6 +11,7 @@ Docs: https://docs.clawd.bot
### Fixes
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
- Models: allow Bedrock custom providers without API keys and preserve inline provider IDs. (#1286) — thanks @alauppe.
## 2026.1.19-2

View File

@ -280,4 +280,94 @@ describe("getApiKeyForModel", () => {
}
}
});
it("accepts AWS profile auth for amazon-bedrock", async () => {
const previousProfile = process.env.AWS_PROFILE;
const previousAccess = process.env.AWS_ACCESS_KEY_ID;
const previousSecret = process.env.AWS_SECRET_ACCESS_KEY;
const previousBearer = process.env.AWS_BEARER_TOKEN_BEDROCK;
try {
process.env.AWS_PROFILE = "bedrock-test";
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
vi.resetModules();
const { resolveApiKeyForProvider } = await import("./model-auth.js");
const resolved = await resolveApiKeyForProvider({
provider: "amazon-bedrock",
store: { version: 1, profiles: {} },
});
expect(resolved.apiKey).toBe("<authenticated>");
expect(resolved.source).toContain("AWS_PROFILE");
} finally {
if (previousProfile === undefined) {
delete process.env.AWS_PROFILE;
} else {
process.env.AWS_PROFILE = previousProfile;
}
if (previousAccess === undefined) {
delete process.env.AWS_ACCESS_KEY_ID;
} else {
process.env.AWS_ACCESS_KEY_ID = previousAccess;
}
if (previousSecret === undefined) {
delete process.env.AWS_SECRET_ACCESS_KEY;
} else {
process.env.AWS_SECRET_ACCESS_KEY = previousSecret;
}
if (previousBearer === undefined) {
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
} else {
process.env.AWS_BEARER_TOKEN_BEDROCK = previousBearer;
}
}
});
it("allows amazon-bedrock without an API key", async () => {
const previousProfile = process.env.AWS_PROFILE;
const previousAccess = process.env.AWS_ACCESS_KEY_ID;
const previousSecret = process.env.AWS_SECRET_ACCESS_KEY;
const previousBearer = process.env.AWS_BEARER_TOKEN_BEDROCK;
try {
delete process.env.AWS_PROFILE;
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
vi.resetModules();
const { resolveApiKeyForProvider } = await import("./model-auth.js");
const resolved = await resolveApiKeyForProvider({
provider: "amazon-bedrock",
store: { version: 1, profiles: {} },
});
expect(resolved.apiKey).toBe("<authenticated>");
expect(resolved.source).toBe("aws-sdk");
} finally {
if (previousProfile === undefined) {
delete process.env.AWS_PROFILE;
} else {
process.env.AWS_PROFILE = previousProfile;
}
if (previousAccess === undefined) {
delete process.env.AWS_ACCESS_KEY_ID;
} else {
process.env.AWS_ACCESS_KEY_ID = previousAccess;
}
if (previousSecret === undefined) {
delete process.env.AWS_SECRET_ACCESS_KEY;
} else {
process.env.AWS_SECRET_ACCESS_KEY = previousSecret;
}
if (previousBearer === undefined) {
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
} else {
process.env.AWS_BEARER_TOKEN_BEDROCK = previousBearer;
}
}
});
});

View File

@ -36,6 +36,7 @@ export async function resolveApiKeyForProvider(params: {
}): Promise<{ apiKey: string; profileId?: string; source: string }> {
const { provider, cfg, profileId, preferredProfile } = params;
const store = params.store ?? ensureAuthProfileStore(params.agentDir);
const normalized = normalizeProviderId(provider);
if (profileId) {
const resolved = await resolveApiKeyForProfile({
@ -88,6 +89,10 @@ export async function resolveApiKeyForProvider(params: {
return { apiKey: customKey, source: "models.json" };
}
if (normalized === "amazon-bedrock") {
return { apiKey: "<authenticated>", source: "aws-sdk" };
}
if (provider === "openai") {
const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0;
if (hasCodex) {
@ -120,6 +125,12 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
const source = applied.has(envVar) ? `shell env: ${envVar}` : `env: ${envVar}`;
return { apiKey: value, source };
};
const pickPresent = (envVar: string): EnvApiKeyResult | null => {
const value = process.env[envVar]?.trim();
if (!value) return null;
const source = applied.has(envVar) ? `shell env: ${envVar}` : `env: ${envVar}`;
return { apiKey: "<authenticated>", source };
};
if (normalized === "github-copilot") {
return pick("COPILOT_GITHUB_TOKEN") ?? pick("GH_TOKEN") ?? pick("GITHUB_TOKEN");
@ -143,6 +154,21 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
return { apiKey: envKey, source: "gcloud adc" };
}
if (normalized === "amazon-bedrock") {
return (
pickPresent("AWS_BEARER_TOKEN_BEDROCK") ??
pickPresent("AWS_PROFILE") ??
(process.env.AWS_ACCESS_KEY_ID?.trim() && process.env.AWS_SECRET_ACCESS_KEY?.trim()
? {
apiKey: "<authenticated>",
source: applied.has("AWS_ACCESS_KEY_ID")
? "shell env: AWS_ACCESS_KEY_ID"
: "env: AWS_ACCESS_KEY_ID",
}
: null)
);
}
if (normalized === "opencode") {
return pick("OPENCODE_API_KEY") ?? pick("OPENCODE_ZEN_API_KEY");
}

View File

@ -143,7 +143,10 @@ export function normalizeProviders(params: {
provider: normalizedKey,
store: authStore,
});
const apiKey = fromEnv ?? fromProfiles;
const apiKey =
fromEnv ??
fromProfiles ??
(normalizedKey === "amazon-bedrock" ? "AWS_PROFILE" : undefined);
if (apiKey?.trim()) {
mutated = true;
normalizedProvider = { ...normalizedProvider, apiKey };

View File

@ -39,10 +39,10 @@ export function resolveModel(
if (!model) {
const providers = cfg?.models?.providers ?? {};
const inlineModels =
providers[provider]?.models ??
Object.values(providers)
.flatMap((entry) => entry?.models ?? [])
.map((entry) => ({ ...entry, provider }));
providers[provider]?.models?.map((entry) => ({ ...entry, provider })) ??
Object.entries(providers).flatMap(([providerId, entry]) =>
(entry?.models ?? []).map((modelEntry) => ({ ...modelEntry, provider: providerId })),
);
const inlineMatch = inlineModels.find((entry) => entry.id === modelId);
if (inlineMatch) {
const normalized = normalizeModelCompat(inlineMatch as Model<Api>);

View File

@ -3,7 +3,8 @@ export type ModelApi =
| "openai-responses"
| "anthropic-messages"
| "google-generative-ai"
| "github-copilot";
| "github-copilot"
| "bedrock-converse-stream";
export type ModelCompatConfig = {
supportsStore?: boolean;

View File

@ -8,6 +8,7 @@ export const ModelApiSchema = z.union([
z.literal("anthropic-messages"),
z.literal("google-generative-ai"),
z.literal("github-copilot"),
z.literal("bedrock-converse-stream"),
]);
export const ModelCompatSchema = z