mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 17:49:53 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ebc6d2146 |
@@ -4,12 +4,7 @@ import { Headers } from "effect/unstable/http"
|
||||
import { Auth, type AuthInput } from "../../route/auth"
|
||||
import { ProviderShared } from "../shared"
|
||||
|
||||
/**
|
||||
* AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth,
|
||||
* which provider facades configure as route auth instead of SigV4. STS-vended
|
||||
* credentials should be refreshed by the consumer (rebuild the model) before
|
||||
* they expire; the route does not refresh.
|
||||
*/
|
||||
/** AWS credentials for SigV4 signing. */
|
||||
export interface Credentials {
|
||||
readonly region: string
|
||||
readonly accessKeyId: string
|
||||
@@ -17,6 +12,8 @@ export interface Credentials {
|
||||
readonly sessionToken?: string
|
||||
}
|
||||
|
||||
export type CredentialProvider = () => Promise<Credentials>
|
||||
|
||||
const signRequest = (input: {
|
||||
readonly url: string
|
||||
readonly body: string
|
||||
@@ -48,7 +45,7 @@ const signRequest = (input: {
|
||||
|
||||
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
|
||||
export const sigV4 = (
|
||||
credentials: Credentials | undefined,
|
||||
credentials: Credentials | CredentialProvider | undefined,
|
||||
options: { readonly service?: string; readonly name?: string } = {},
|
||||
) =>
|
||||
Auth.custom((input: AuthInput) => {
|
||||
@@ -58,12 +55,22 @@ export const sigV4 = (
|
||||
`${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`,
|
||||
)
|
||||
}
|
||||
const resolved =
|
||||
typeof credentials === "function"
|
||||
? yield* Effect.tryPromise({
|
||||
try: credentials,
|
||||
catch: (error) =>
|
||||
ProviderShared.invalidRequest(
|
||||
`${options.name ?? "Bedrock Converse"} credential resolution failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
})
|
||||
: credentials
|
||||
const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
|
||||
const signed = yield* signRequest({
|
||||
url: input.url,
|
||||
body: input.body,
|
||||
headers: headersForSigning,
|
||||
credentials,
|
||||
credentials: resolved,
|
||||
service: options.service ?? "bedrock",
|
||||
name: options.name ?? "Bedrock Converse",
|
||||
})
|
||||
|
||||
@@ -4,13 +4,14 @@ import type { ProviderPackage } from "../provider-package"
|
||||
import { ProviderID, type ModelID } from "../schema"
|
||||
import * as BedrockConverse from "../protocols/bedrock-converse"
|
||||
import type { BedrockCredentials } from "../protocols/bedrock-converse"
|
||||
import type { CredentialProvider } from "../protocols/utils/bedrock-auth"
|
||||
|
||||
export const id = ProviderID.make("amazon-bedrock")
|
||||
|
||||
export type Config = RouteDefaultsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly credentials?: BedrockCredentials
|
||||
readonly credentials?: BedrockCredentials | CredentialProvider
|
||||
/** AWS region. Defaults to `us-east-1` when neither this nor `credentials.region` is set. */
|
||||
readonly region?: string
|
||||
/** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */
|
||||
@@ -21,7 +22,7 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
readonly credentials?: BedrockCredentials
|
||||
readonly credentials?: BedrockCredentials | CredentialProvider
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
}
|
||||
@@ -31,7 +32,7 @@ const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.am
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
const { apiKey, credentials, region, baseURL, ...rest } = input
|
||||
const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
|
||||
const resolvedRegion = region ?? (typeof credentials === "function" ? undefined : credentials?.region) ?? "us-east-1"
|
||||
return BedrockConverse.route.with({
|
||||
...rest,
|
||||
provider: id,
|
||||
|
||||
@@ -519,14 +519,8 @@ describe("Bedrock Converse route", () => {
|
||||
fixedBytes(
|
||||
eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
[
|
||||
"contentBlockDelta",
|
||||
{ contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } },
|
||||
],
|
||||
[
|
||||
"contentBlockDelta",
|
||||
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
|
||||
],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
),
|
||||
),
|
||||
@@ -561,10 +555,7 @@ describe("Bedrock Converse route", () => {
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
[
|
||||
"contentBlockDelta",
|
||||
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
|
||||
],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
)
|
||||
@@ -760,6 +751,26 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves fresh SigV4 credentials for each request", () => {
|
||||
let calls = 0
|
||||
const signed = AmazonBedrock.configure({
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
credentials: async () => {
|
||||
calls++
|
||||
return {
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
||||
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
}
|
||||
},
|
||||
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
|
||||
return LLMClient.generate(LLMRequest.update(baseRequest, { model: signed })).pipe(
|
||||
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))),
|
||||
Effect.tap(() => Effect.sync(() => expect(calls).toBe(1))),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = new CacheHint({ type: "ephemeral" })
|
||||
|
||||
@@ -88,7 +88,9 @@ function mapBedrockSettings(
|
||||
: typeof settings.bearerToken === "string"
|
||||
? settings.bearerToken
|
||||
: undefined
|
||||
const credentials = mapBedrockCredentials(settings)
|
||||
const credentials =
|
||||
mapBedrockCredentials(settings) ??
|
||||
(typeof settings.credentialProvider === "function" ? settings.credentialProvider : undefined)
|
||||
return {
|
||||
...baseSettings,
|
||||
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
|
||||
|
||||
@@ -59,18 +59,40 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
|
||||
return sdk.responses(modelID)
|
||||
}
|
||||
|
||||
function defaultCredentialProvider(profile: string | undefined, region: string) {
|
||||
const load = import("@aws-sdk/credential-providers").then((mod) =>
|
||||
mod.fromNodeProviderChain(profile ? { profile } : {}),
|
||||
)
|
||||
return async () => ({ ...(await (await load)()), region })
|
||||
}
|
||||
|
||||
export const AmazonBedrockPlugin = define({
|
||||
id: "opencode.provider.amazon-bedrock",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (!Provider.isAISDK(item.provider.package)) continue
|
||||
if (Provider.packageName(item.provider.package) !== "@ai-sdk/amazon-bedrock") continue
|
||||
if (
|
||||
!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(
|
||||
Provider.packageName(item.provider.package) ?? "",
|
||||
)
|
||||
)
|
||||
continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (typeof provider.settings?.endpoint !== "string") return
|
||||
const settings = provider.settings ?? {}
|
||||
const profile = typeof settings.profile === "string" ? settings.profile : process.env.AWS_PROFILE
|
||||
const region = typeof settings.region === "string" ? settings.region : (process.env.AWS_REGION ?? "us-east-1")
|
||||
provider.settings = {
|
||||
...settings,
|
||||
region,
|
||||
...(typeof settings.credentialProvider === "function"
|
||||
? {}
|
||||
: { credentialProvider: defaultCredentialProvider(profile, region) }),
|
||||
}
|
||||
if (typeof settings.endpoint !== "string") return
|
||||
// The AI SDK expects a base URL, but users configure Bedrock private/VPC
|
||||
// endpoints as `endpoint`; move it into the catalog endpoint URL once.
|
||||
provider.settings.baseURL = provider.settings.endpoint
|
||||
provider.settings.baseURL = settings.endpoint
|
||||
delete provider.settings.endpoint
|
||||
})
|
||||
}
|
||||
@@ -95,8 +117,7 @@ export const AmazonBedrockPlugin = define({
|
||||
if (!bearerToken && options.credentialProvider === undefined) {
|
||||
// Do not gate SDK creation on explicit AWS env vars. The default chain
|
||||
// also handles ~/.aws/credentials, SSO, process creds, and instance roles.
|
||||
const { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers"))
|
||||
options.credentialProvider = fromNodeProviderChain(profile ? { profile } : {})
|
||||
options.credentialProvider = defaultCredentialProvider(profile, region)
|
||||
}
|
||||
|
||||
if (evt.package === "@ai-sdk/amazon-bedrock/mantle") {
|
||||
|
||||
@@ -158,6 +158,18 @@ describe("AISDKNative", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("maps a Bedrock credential provider to the native runtime", () => {
|
||||
const credentialProvider = async () => ({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "key",
|
||||
secretAccessKey: "secret",
|
||||
})
|
||||
|
||||
for (const packageName of ["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"]) {
|
||||
expect(map(packageName, { credentialProvider })?.settings).toMatchObject({ credentials: credentialProvider })
|
||||
}
|
||||
})
|
||||
|
||||
test("maps the legacy Bedrock endpoint override", () => {
|
||||
expect(
|
||||
map(
|
||||
|
||||
@@ -80,24 +80,66 @@ function openAIUrl(language: unknown, path: string, modelId: string) {
|
||||
|
||||
describe("AmazonBedrockPlugin", () => {
|
||||
it.effect("moves endpoint setting to baseURL", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const bedrock = Provider.Info.make({
|
||||
...Provider.Info.empty(Provider.ID.amazonBedrock),
|
||||
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
|
||||
settings: { endpoint: "https://bedrock.example" },
|
||||
withEnv({ AWS_PROFILE: undefined, AWS_REGION: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const bedrock = Provider.Info.make({
|
||||
...Provider.Info.empty(Provider.ID.amazonBedrock),
|
||||
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
|
||||
settings: { endpoint: "https://bedrock.example" },
|
||||
})
|
||||
catalog.provider.update(bedrock.id, (item) => {
|
||||
item.package = bedrock.package
|
||||
item.settings = { endpoint: "https://bedrock.example" }
|
||||
})
|
||||
})
|
||||
catalog.provider.update(bedrock.id, (item) => {
|
||||
item.package = bedrock.package
|
||||
item.settings = { endpoint: "https://bedrock.example" }
|
||||
yield* addPlugin()
|
||||
const result = required(yield* catalog.provider.get(Provider.ID.amazonBedrock))
|
||||
expect(result.package).toBe(Provider.aisdk("@ai-sdk/amazon-bedrock"))
|
||||
expect(result.settings).toMatchObject({
|
||||
baseURL: "https://bedrock.example",
|
||||
region: "us-east-1",
|
||||
credentialProvider: expect.any(Function),
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
const result = required(yield* catalog.provider.get(Provider.ID.amazonBedrock))
|
||||
expect(result.package).toBe(Provider.aisdk("@ai-sdk/amazon-bedrock"))
|
||||
expect(result.settings).toEqual({ baseURL: "https://bedrock.example" })
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("discovers AWS credentials for the native Bedrock runtime", () =>
|
||||
withEnv(
|
||||
{
|
||||
AWS_ACCESS_KEY_ID: "key",
|
||||
AWS_SECRET_ACCESS_KEY: "secret",
|
||||
AWS_SESSION_TOKEN: "session",
|
||||
AWS_PROFILE: undefined,
|
||||
AWS_REGION: "eu-west-1",
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const bedrock = Provider.Info.make({
|
||||
...Provider.Info.empty(Provider.ID.amazonBedrock),
|
||||
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
|
||||
})
|
||||
catalog.provider.update(bedrock.id, (item) => {
|
||||
item.package = bedrock.package
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
const result = required(yield* catalog.provider.get(Provider.ID.amazonBedrock))
|
||||
const credentialProvider = result.settings?.credentialProvider
|
||||
if (typeof credentialProvider !== "function") throw new Error("Expected credential provider")
|
||||
const credentials = yield* Effect.promise(() => credentialProvider())
|
||||
expect(credentials).toMatchObject({
|
||||
region: "eu-west-1",
|
||||
accessKeyId: "key",
|
||||
secretAccessKey: "secret",
|
||||
sessionToken: "session",
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("prefers endpoint over baseURL for SDK base URL", () =>
|
||||
|
||||
@@ -407,7 +407,7 @@ export function RunCommandMenuBody(props: {
|
||||
name: "compact",
|
||||
display: "Compact session",
|
||||
footer: "/compact",
|
||||
keywords: "compact session context",
|
||||
keywords: "compact summarize session context",
|
||||
},
|
||||
{
|
||||
action: "slash",
|
||||
|
||||
@@ -406,7 +406,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
kind: "slash",
|
||||
name: "compact",
|
||||
display: "/compact",
|
||||
description: "compact older session context to free space",
|
||||
description: "summarize the session to reduce context usage",
|
||||
} satisfies SlashOption,
|
||||
{ kind: "slash", name: "exit", display: "/exit", description: "close OpenCode" } satisfies SlashOption,
|
||||
]
|
||||
|
||||
@@ -54,7 +54,8 @@ export function isNewCommand(input: string): boolean {
|
||||
}
|
||||
|
||||
export function isCompactCommand(input: string): boolean {
|
||||
return input.trim().toLowerCase() === "/compact"
|
||||
const text = input.trim().toLowerCase()
|
||||
return text === "/compact" || text === "/summarize"
|
||||
}
|
||||
|
||||
export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
|
||||
|
||||
@@ -635,6 +635,7 @@ export function Session() {
|
||||
group: "Session",
|
||||
slash: {
|
||||
name: "compact",
|
||||
aliases: ["summarize"],
|
||||
},
|
||||
run: () => {
|
||||
void client.api.session.compact({ sessionID: route.sessionID })
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
createPromptHistory,
|
||||
isCompactCommand,
|
||||
isExitCommand,
|
||||
isNewCommand,
|
||||
movePromptHistory,
|
||||
@@ -99,10 +98,4 @@ describe("run prompt shared", () => {
|
||||
expect(isNewCommand(" /NEW ")).toBe(true)
|
||||
expect(isNewCommand("/new now")).toBe(false)
|
||||
})
|
||||
|
||||
test("recognizes only the compact command", () => {
|
||||
expect(isCompactCommand("/compact")).toBe(true)
|
||||
expect(isCompactCommand(" /COMPACT ")).toBe(true)
|
||||
expect(isCompactCommand("/summarize")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,7 +80,7 @@ describe("run runtime queue", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("treats /compact as a local compaction command", async () => {
|
||||
test.each(["/compact", "/summarize"])("treats %s as a local compaction command", async (command) => {
|
||||
const ui = createFooterApiFixture()
|
||||
const seen: string[] = []
|
||||
let compacted = 0
|
||||
@@ -96,7 +96,7 @@ describe("run runtime queue", () => {
|
||||
},
|
||||
})
|
||||
|
||||
ui.submit("/compact")
|
||||
ui.submit(command)
|
||||
ui.submit("hello")
|
||||
await task
|
||||
|
||||
|
||||
Reference in New Issue
Block a user