mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 17:26:22 -04:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c896dbfca | |||
| 7bfa594f29 | |||
| 2593d1c724 | |||
| f07a9adaac | |||
| 287d54f2f3 | |||
| b29cf137ea | |||
| 24b725c0d1 | |||
| 7548c62088 | |||
| cee144dc9a | |||
| f0ddec2f48 | |||
| 6388d2d4b4 | |||
| b3449758b6 | |||
| e75020b6d9 | |||
| adf1a56337 | |||
| aa820db18d | |||
| 42e8d11552 | |||
| 28d784b83a | |||
| a4ad17347f |
@@ -20,6 +20,7 @@ export type LanguageModelOptions = AzureURL &
|
||||
readonly apiVersion?: string
|
||||
readonly queryParams?: Record<string, string>
|
||||
readonly useCompletionUrls?: boolean
|
||||
readonly useDeploymentBasedUrls?: boolean
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Config = LanguageModelOptions
|
||||
@@ -29,27 +30,22 @@ export type Settings = ProviderPackage.Settings &
|
||||
readonly apiKey?: string
|
||||
readonly apiVersion?: string
|
||||
readonly queryParams?: Readonly<Record<string, string>>
|
||||
readonly useDeploymentBasedUrls?: boolean
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1`
|
||||
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai`
|
||||
|
||||
const responsesRoute = OpenAIResponses.route.with({
|
||||
id: "azure-openai-responses",
|
||||
provider: id,
|
||||
auth: routeAuth,
|
||||
endpoint: {
|
||||
query: { "api-version": "v1" },
|
||||
},
|
||||
})
|
||||
|
||||
const chatRoute = OpenAIChat.route.with({
|
||||
id: "azure-openai-chat",
|
||||
provider: id,
|
||||
auth: routeAuth,
|
||||
endpoint: {
|
||||
query: { "api-version": "v1" },
|
||||
},
|
||||
})
|
||||
|
||||
export const routes = [responsesRoute, chatRoute]
|
||||
@@ -60,6 +56,7 @@ const defaults = (input: Config) => {
|
||||
apiVersion: _apiVersion,
|
||||
resourceName: _resourceName,
|
||||
useCompletionUrls: _useCompletionUrls,
|
||||
useDeploymentBasedUrls: _useDeploymentBasedUrls,
|
||||
baseURL: _baseURL,
|
||||
queryParams: _queryParams,
|
||||
...rest
|
||||
@@ -80,31 +77,35 @@ const auth = (input: Config) => {
|
||||
)
|
||||
}
|
||||
|
||||
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) =>
|
||||
route.with({
|
||||
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config, modelID: string | ModelID) => {
|
||||
const baseURL = input.baseURL ?? resourceBaseURL(input.resourceName!)
|
||||
const azure = input.baseURL === undefined || new URL(input.baseURL).hostname.endsWith(".openai.azure.com")
|
||||
return route.with({
|
||||
auth: auth(input),
|
||||
endpoint: {
|
||||
// AtLeastOne guarantees at least one is set; baseURL wins if both are.
|
||||
baseURL: input.baseURL ?? resourceBaseURL(input.resourceName!),
|
||||
baseURL: input.useDeploymentBasedUrls
|
||||
? `${baseURL.replace(/\/$/, "")}/deployments/${modelID}`
|
||||
: azure
|
||||
? `${baseURL.replace(/\/$/, "")}/v1`
|
||||
: baseURL,
|
||||
query: {
|
||||
...(input.apiVersion ? { "api-version": input.apiVersion } : {}),
|
||||
...(azure || input.useDeploymentBasedUrls ? { "api-version": input.apiVersion ?? "v1" } : {}),
|
||||
...input.queryParams,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const configure = (input: Config) => {
|
||||
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
|
||||
const configuredChatRoute = configuredRoute(chatRoute, input)
|
||||
const modelDefaults = defaults(input)
|
||||
|
||||
const responses = (modelID: string | ModelID) =>
|
||||
configuredResponsesRoute
|
||||
configuredRoute(responsesRoute, input, modelID)
|
||||
.with(withOpenAIOptions(modelID, modelDefaults))
|
||||
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
|
||||
const chat = (modelID: string | ModelID) =>
|
||||
configuredChatRoute
|
||||
configuredRoute(chatRoute, input, modelID)
|
||||
.with(withOpenAIOptions(modelID, modelDefaults))
|
||||
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
|
||||
@@ -131,6 +132,7 @@ const config = (settings: Settings): Config => {
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
||||
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
|
||||
}
|
||||
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
|
||||
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
|
||||
|
||||
@@ -15,8 +15,7 @@ export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
|
||||
|
||||
const VERSION = "vertex-2023-10-16" as const
|
||||
|
||||
// models.dev uses this provider id even though the API contract is Anthropic Messages.
|
||||
export const id = ProviderID.make("google-vertex-anthropic")
|
||||
export const id = ProviderID.make("google-vertex")
|
||||
|
||||
export type Config = RouteDefaultsInput &
|
||||
GoogleVertexShared.OAuthOptions & {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
|
||||
import { RequestExecutor } from "./executor"
|
||||
import { Framing } from "./framing"
|
||||
import { HttpTransport } from "./transport"
|
||||
import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
|
||||
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport"
|
||||
import { WebSocketExecutor } from "./transport"
|
||||
import type { Protocol } from "./protocol"
|
||||
import { applyCachePolicy } from "../cache-policy"
|
||||
@@ -155,7 +155,7 @@ export interface Interface {
|
||||
}
|
||||
|
||||
export interface StreamOptions {
|
||||
readonly transform?: HttpRequestTransform
|
||||
readonly http?: HttpMiddleware
|
||||
}
|
||||
|
||||
export interface StreamMethod {
|
||||
@@ -307,7 +307,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
auth: routeInput.auth ?? Auth.none,
|
||||
encodeBody,
|
||||
headers: routeInput.headers,
|
||||
transform: options?.transform,
|
||||
middleware: options?.http,
|
||||
}),
|
||||
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
|
||||
const route = `${request.model.provider}/${request.model.route.id}`
|
||||
|
||||
@@ -20,9 +20,18 @@ import { classifyProviderFailure } from "../provider-error"
|
||||
export interface Interface {
|
||||
readonly execute: (
|
||||
request: HttpClientRequest.HttpClientRequest,
|
||||
middleware?: HttpMiddleware,
|
||||
) => Effect.Effect<HttpClientResponse.HttpClientResponse, AIError>
|
||||
}
|
||||
|
||||
export type HttpHandler = (
|
||||
request: HttpClientRequest.HttpClientRequest,
|
||||
) => Effect.Effect<HttpClientResponse.HttpClientResponse, Error>
|
||||
export type HttpMiddleware = (
|
||||
request: HttpClientRequest.HttpClientRequest,
|
||||
handler: HttpHandler,
|
||||
) => Effect.Effect<HttpClientResponse.HttpClientResponse, Error>
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
|
||||
|
||||
const BODY_LIMIT = 16_384
|
||||
@@ -261,7 +270,7 @@ const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: u
|
||||
return transportError({ message: error.message, kind: "Timeout" })
|
||||
}
|
||||
if (!HttpClientError.isHttpClientError(error)) {
|
||||
return transportError({ message: "HTTP transport failed" })
|
||||
return transportError({ message: error instanceof Error ? error.message : "HTTP transport failed" })
|
||||
}
|
||||
const request = "request" in error ? error.request : undefined
|
||||
if (error.reason._tag === "TransportError") {
|
||||
@@ -282,12 +291,20 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const executeOnce = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
|
||||
Effect.gen(function* () {
|
||||
const redactedNames = yield* Headers.CurrentRedactedNames
|
||||
return yield* http
|
||||
.execute(request)
|
||||
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
|
||||
if (!middleware)
|
||||
return yield* http
|
||||
.execute(request)
|
||||
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
|
||||
|
||||
const response = yield* middleware(request, (input) =>
|
||||
http
|
||||
.execute(input)
|
||||
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||
).pipe(Effect.mapError(toHttpError(redactedNames)))
|
||||
return yield* statusError(response.request, redactedNames)(response)
|
||||
})
|
||||
return Service.of({
|
||||
execute: executeOnce,
|
||||
|
||||
@@ -23,4 +23,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
|
||||
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
|
||||
export type { Definition as FramingDef } from "./framing"
|
||||
export type { Protocol as ProtocolDef } from "./protocol"
|
||||
export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
|
||||
export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Auth } from "../auth"
|
||||
import { render as renderEndpoint } from "../endpoint"
|
||||
import { Framing } from "../framing"
|
||||
import type { Transport, TransportPrepareInput } from "./index"
|
||||
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index"
|
||||
import * as ProviderShared from "../../protocols/shared"
|
||||
import { mergeJsonRecords, type LLMRequest } from "../../schema"
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface JsonRequestParts<Body = unknown> {
|
||||
export interface HttpPrepared<Frame> {
|
||||
readonly request: HttpClientRequest.HttpClientRequest
|
||||
readonly framing: Framing.Definition<Frame>
|
||||
readonly middleware?: HttpMiddleware
|
||||
}
|
||||
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
@@ -74,21 +75,21 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||
prepare: (prepareInput) =>
|
||||
Effect.gen(function* () {
|
||||
const parts = yield* jsonRequestParts({ ...prepareInput })
|
||||
const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
|
||||
yield* (prepareInput.transform?.(request) ?? Effect.void)
|
||||
const request = ProviderShared.jsonPost({
|
||||
url: parts.url,
|
||||
body: parts.bodyText,
|
||||
headers: parts.headers,
|
||||
})
|
||||
return {
|
||||
request: ProviderShared.jsonPost({
|
||||
url: request.url,
|
||||
body: request.body ?? "",
|
||||
headers: Headers.fromInput(request.headers),
|
||||
}),
|
||||
request,
|
||||
framing: input.framing,
|
||||
middleware: prepareInput.middleware,
|
||||
}
|
||||
}),
|
||||
frames: (prepared, request, runtime) =>
|
||||
Stream.unwrap(
|
||||
runtime.http
|
||||
.execute(prepared.request)
|
||||
.execute(prepared.request, prepared.middleware)
|
||||
.pipe(
|
||||
Effect.map((response) =>
|
||||
prepared.framing.frame(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Effect, Stream } from "effect"
|
||||
import { Endpoint } from "../endpoint"
|
||||
import { Auth } from "../auth"
|
||||
import type { Interface as RequestExecutorInterface } from "../executor"
|
||||
import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor"
|
||||
import type { Interface as WebSocketExecutorInterface } from "./websocket"
|
||||
import type { AIError, LLMRequest } from "../../schema"
|
||||
|
||||
@@ -10,15 +10,6 @@ export interface TransportRuntime {
|
||||
readonly webSocket?: WebSocketExecutorInterface
|
||||
}
|
||||
|
||||
export interface HttpRequest {
|
||||
url: string
|
||||
readonly method: string
|
||||
headers: Record<string, string>
|
||||
body: string | undefined
|
||||
}
|
||||
|
||||
export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect<void>
|
||||
|
||||
export interface Transport<Body, Prepared, Frame> {
|
||||
readonly id: string
|
||||
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError>
|
||||
@@ -32,8 +23,9 @@ export interface TransportPrepareInput<Body> {
|
||||
readonly auth: Auth.Definition
|
||||
readonly encodeBody: (body: Body) => string
|
||||
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
||||
readonly transform?: HttpRequestTransform
|
||||
readonly middleware?: HttpMiddleware
|
||||
}
|
||||
|
||||
export * as HttpTransport from "./http"
|
||||
export type { HttpHandler, HttpMiddleware } from "../executor"
|
||||
export { WebSocketExecutor, WebSocketTransport } from "./websocket"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { Effect, Ref, Schema } from "effect"
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { LLM, mergeProviderOptions } from "../src"
|
||||
import { AnthropicMessages, OpenAIChat } from "../src/protocols"
|
||||
import { Auth, LLMClient } from "../src/route"
|
||||
@@ -146,12 +146,16 @@ describe("request option precedence", () => {
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
{
|
||||
transform: (request) =>
|
||||
Effect.sync(() => {
|
||||
expect(request.headers.authorization).toBe("Bearer fresh-key")
|
||||
request.url = "https://proxy.test/v1/chat/completions"
|
||||
request.headers["x-plugin"] = "transformed"
|
||||
request.body = JSON.stringify({ transformed: true })
|
||||
http: (request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
return yield* handler(
|
||||
request.pipe(
|
||||
HttpClientRequest.setUrl("https://proxy.test/v1/chat/completions"),
|
||||
HttpClientRequest.setMethod("PUT"),
|
||||
HttpClientRequest.setHeader("x-plugin", "transformed"),
|
||||
HttpClientRequest.bodyText(JSON.stringify({ transformed: true }), "application/custom+json"),
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
).pipe(
|
||||
@@ -160,7 +164,9 @@ describe("request option precedence", () => {
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(web.url).toBe("https://proxy.test/v1/chat/completions")
|
||||
expect(web.method).toBe("PUT")
|
||||
expect(web.headers.get("x-plugin")).toBe("transformed")
|
||||
expect(web.headers.get("content-type")).toBe("application/custom+json")
|
||||
expect(decodeJson(input.text)).toEqual({ transformed: true })
|
||||
return input.respond(sseEvents(deltaChunk({}, "stop")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
@@ -171,6 +177,82 @@ describe("request option precedence", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("transforms the HTTP response before protocol decoding", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-4o-mini" }),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
{
|
||||
http: (request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* handler(request)
|
||||
return HttpClientResponse.fromWeb(
|
||||
response.request,
|
||||
new Response((yield* response.text).replace("network", "hooked"), {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
},
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.succeed(
|
||||
input.respond(sseEvents(deltaChunk({ content: "network" }, "stop")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("hooked")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("can inspect an error response and retry the native request", () =>
|
||||
Effect.gen(function* () {
|
||||
const attempts = yield* Ref.make(0)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("stale") })
|
||||
.model({ id: "gpt-4o-mini" }),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
{
|
||||
http: (request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* handler(request)
|
||||
expect(response.status).toBe(401)
|
||||
return yield* handler(HttpClientRequest.setHeader(request, "authorization", "Bearer refreshed"))
|
||||
}),
|
||||
},
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(attempts, (value) => value + 1)
|
||||
if (input.request.headers.authorization !== "Bearer refreshed")
|
||||
return input.respond("unauthorized", { status: 401 })
|
||||
return input.respond(sseEvents(deltaChunk({ content: "retried" }, "stop")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("retried")
|
||||
expect(yield* Ref.get(attempts)).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies raw body overlays after protocol lowering", () =>
|
||||
LLMClient.generate(
|
||||
LLM.request({
|
||||
|
||||
@@ -67,6 +67,18 @@ const expectAIError = (error: unknown) => {
|
||||
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
|
||||
|
||||
describe("RequestExecutor", () => {
|
||||
it.effect("preserves middleware error messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor
|
||||
.execute(request, () => Effect.fail(new Error("plugin rejected request")))
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason.message).toBe("plugin rejected request")
|
||||
}).pipe(Effect.provide(responsesLayer([]))),
|
||||
)
|
||||
|
||||
it.effect("classifies context overflow responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
|
||||
@@ -209,6 +209,26 @@ describe("provider package entrypoints", () => {
|
||||
expect(chat.route.id).toBe("azure-openai-chat")
|
||||
})
|
||||
|
||||
test("constructs Azure deployment URLs and preserves custom gateway URLs", async () => {
|
||||
const Azure = await import("@opencode-ai/ai/providers/azure")
|
||||
const deployment = Azure.model("custom-deployment", {
|
||||
apiKey: "fixture",
|
||||
resourceName: "opencode-test",
|
||||
apiVersion: "2025-01-01-preview",
|
||||
useDeploymentBasedUrls: true,
|
||||
})
|
||||
const gateway = Azure.model("gateway-model", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://gateway.example/azure",
|
||||
})
|
||||
|
||||
expect(deployment.route.endpoint).toMatchObject({
|
||||
baseURL: "https://opencode-test.openai.azure.com/openai/deployments/custom-deployment",
|
||||
query: { "api-version": "2025-01-01-preview" },
|
||||
})
|
||||
expect(gateway.route.endpoint).toMatchObject({ baseURL: "https://gateway.example/azure", query: {} })
|
||||
})
|
||||
|
||||
test("maps Google package settings onto the Gemini model", async () => {
|
||||
const Google = await import("@opencode-ai/ai/providers/google")
|
||||
const selected = Google.model("gemini-2.5-flash", {
|
||||
|
||||
@@ -56,13 +56,14 @@ describe("Google Vertex providers", () => {
|
||||
|
||||
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = GoogleVertexMessages.configure({
|
||||
accessToken: "vertex-token",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
}).model("claude-sonnet-4-6")
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: GoogleVertexMessages.configure({
|
||||
accessToken: "vertex-token",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
}).model("claude-sonnet-4-6"),
|
||||
model,
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
).pipe(
|
||||
@@ -97,6 +98,7 @@ describe("Google Vertex providers", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(model.provider).toBe("google-vertex")
|
||||
expect(response.text).toBe("Hello.")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1502,18 +1502,25 @@ export interface ProjectCopyApi<E = never> {
|
||||
export type Endpoint25_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
|
||||
export type VcsStatusOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
|
||||
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
|
||||
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
|
||||
|
||||
export type Endpoint25_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
|
||||
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
||||
|
||||
export type Endpoint25_2Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: Vcs.Mode
|
||||
readonly context?: number | undefined
|
||||
}
|
||||
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
|
||||
export type VcsDiffOperation<E = never> = (input: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
||||
export type Endpoint25_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
|
||||
export type VcsDiffOperation<E = never> = (input: Endpoint25_2Input) => Effect.Effect<Endpoint25_2Output, E>
|
||||
|
||||
export interface VcsApi<E = never> {
|
||||
readonly get: VcsGetOperation<E>
|
||||
readonly status: VcsStatusOperation<E>
|
||||
readonly diff: VcsDiffOperation<E>
|
||||
}
|
||||
|
||||
@@ -210,6 +210,8 @@ import type {
|
||||
Endpoint25_0Output,
|
||||
Endpoint25_1Input,
|
||||
Endpoint25_1Output,
|
||||
Endpoint25_2Input,
|
||||
Endpoint25_2Output,
|
||||
Endpoint26_0Output,
|
||||
Endpoint26_1Input,
|
||||
Endpoint26_1Output,
|
||||
@@ -1182,17 +1184,26 @@ const adaptGroup24 = (raw: RawClient["server.projectCopy"]) => ({
|
||||
|
||||
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
|
||||
preserveEffect<Endpoint25_0Output>()(
|
||||
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_1Input) =>
|
||||
preserveEffect<Endpoint25_1Output>()(
|
||||
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_1Input) =>
|
||||
preserveEffect<Endpoint25_1Output>()(
|
||||
const Endpoint25_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) =>
|
||||
preserveEffect<Endpoint25_2Output>()(
|
||||
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint25_0(raw), diff: Endpoint25_1(raw) })
|
||||
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({
|
||||
get: Endpoint25_0(raw),
|
||||
status: Endpoint25_1(raw),
|
||||
diff: Endpoint25_2(raw),
|
||||
})
|
||||
|
||||
const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
|
||||
preserveEffect<Endpoint26_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
@@ -202,6 +202,8 @@ import type {
|
||||
ProjectCopyRemoveOutput,
|
||||
ProjectCopyRefreshInput,
|
||||
ProjectCopyRefreshOutput,
|
||||
VcsGetInput,
|
||||
VcsGetOutput,
|
||||
VcsStatusInput,
|
||||
VcsStatusOutput,
|
||||
VcsDiffInput,
|
||||
@@ -1702,6 +1704,18 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
vcs: {
|
||||
get: (input?: VcsGetInput, requestOptions?: RequestOptions) =>
|
||||
request<VcsGetOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/vcs`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
status: (input?: VcsStatusInput, requestOptions?: RequestOptions) =>
|
||||
request<VcsStatusOutput>(
|
||||
{
|
||||
|
||||
@@ -530,6 +530,8 @@ export type ReferenceGitSource = {
|
||||
|
||||
export type ProjectCopyCopy = { directory: string }
|
||||
|
||||
export type VcsBranch = { current?: string; default?: string }
|
||||
|
||||
export type VcsFileStatus = {
|
||||
file: string
|
||||
additions: number
|
||||
@@ -1745,6 +1747,8 @@ export type SessionStatus2 = {
|
||||
|
||||
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
|
||||
|
||||
export type VcsInfo = { branch: VcsBranch }
|
||||
|
||||
export type PermissionRuleset = Array<PermissionRule>
|
||||
|
||||
export type SessionInfo = {
|
||||
@@ -4902,6 +4906,17 @@ export type ProjectCopyRefreshInput = {
|
||||
|
||||
export type ProjectCopyRefreshOutput = void
|
||||
|
||||
export type VcsGetInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type VcsGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: VcsInfo
|
||||
}
|
||||
|
||||
export type VcsStatusInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
||||
@@ -44,7 +44,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
|
||||
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
|
||||
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["get", "status", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
export * as Agent from "./agent"
|
||||
|
||||
import path from "path"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Array, Context, Effect, Layer, Types } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Bus } from "./bus"
|
||||
import { State } from "./state"
|
||||
|
||||
const SHELL_OUTPUT_GLOB = (data: string) => path.join(data, "shell", "*", "*")
|
||||
const TOOL_OUTPUT_GLOB = (data: string) => path.join(data, "tool-output", "*")
|
||||
|
||||
export const ID = Agent.ID
|
||||
export type ID = typeof ID.Type
|
||||
export const Name = Agent.Name
|
||||
@@ -51,6 +56,13 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const global = yield* Global.Service
|
||||
const permissions: Info["permissions"] = [
|
||||
{ action: "external_directory", resource: SHELL_OUTPUT_GLOB(global.data), effect: "allow" },
|
||||
{ action: "external_directory", resource: TOOL_OUTPUT_GLOB(global.data), effect: "allow" },
|
||||
{ action: "external_directory", resource: path.join(global.tmp, "*"), effect: "allow" },
|
||||
{ action: "external_directory", resource: path.join(global.config, "*"), effect: "allow" },
|
||||
]
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "agent",
|
||||
initial: () => ({ agents: new Map() }),
|
||||
@@ -61,7 +73,13 @@ const layer = Layer.effect(
|
||||
draft.default = id
|
||||
},
|
||||
update: (id, fn) => {
|
||||
const current = draft.agents.get(id) ?? (Info.default(id) as Types.DeepMutable<Info>)
|
||||
const defaults = Info.default(id)
|
||||
const current =
|
||||
draft.agents.get(id) ??
|
||||
({
|
||||
...defaults,
|
||||
permissions: [...defaults.permissions, ...permissions],
|
||||
} as Types.DeepMutable<Info>)
|
||||
if (!draft.agents.has(id)) draft.agents.set(id, current)
|
||||
fn(current)
|
||||
current.id = id
|
||||
@@ -114,4 +132,4 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Global.node] })
|
||||
|
||||
@@ -27,6 +27,21 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
}
|
||||
case "@ai-sdk/amazon-bedrock/mantle":
|
||||
return mapBedrockMantle(input, baseSettings)
|
||||
case "@ai-sdk/azure":
|
||||
return {
|
||||
package: `@opencode-ai/ai/providers/azure/${input.settings.useCompletionUrls === true ? "chat" : "responses"}`,
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
...(typeof input.settings.resourceName === "string" ? { resourceName: input.settings.resourceName } : {}),
|
||||
...(typeof input.settings.apiVersion === "string" ? { apiVersion: input.settings.apiVersion } : {}),
|
||||
...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}),
|
||||
...(typeof input.settings.useDeploymentBasedUrls === "boolean"
|
||||
? { useDeploymentBasedUrls: input.settings.useDeploymentBasedUrls }
|
||||
: {}),
|
||||
...mapOpenAIOptions(input.settings),
|
||||
},
|
||||
}
|
||||
case "@ai-sdk/google":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/google",
|
||||
|
||||
@@ -213,7 +213,7 @@ const layer = Layer.effect(
|
||||
const provider = record.provider
|
||||
|
||||
// TODO: Remove these provider-specific assumptions once model syncing reliably reports available deployments.
|
||||
if (providerID === Provider.ID.azure || providerID === Provider.ID.make("azure-cognitive-services")) {
|
||||
if (providerID === Provider.ID.azure) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -12,96 +12,84 @@ export const Plugin = define({
|
||||
const config = yield* Config.Service
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.integration.transform((integrations) => {
|
||||
const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredIntegrations = new Set(
|
||||
files.flatMap((file) =>
|
||||
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) =>
|
||||
provider.env === undefined ? [] : [id],
|
||||
),
|
||||
),
|
||||
configuredProviders(loaded.entries).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
|
||||
)
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const integrationID = id
|
||||
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
|
||||
integrations.update(integrationID, (integration) => {
|
||||
integration.name = item.name ?? integration.name
|
||||
for (const [id, provider] of configuredProviders(loaded.entries)) {
|
||||
const integrationID = id
|
||||
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
|
||||
integrations.update(integrationID, (integration) => {
|
||||
integration.name = provider.name ?? integration.name
|
||||
})
|
||||
if (provider.env !== undefined) {
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "env", names: [...provider.env] },
|
||||
})
|
||||
if (item.env !== undefined) {
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "env", names: [...item.env] },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredDefault = Config.latest(loaded.entries, "model")
|
||||
if (configuredDefault !== undefined)
|
||||
catalog.model.default.set(configuredDefault.providerID, configuredDefault.model)
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const providerID = id
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.package !== undefined) provider.package = item.package
|
||||
if (item.settings !== undefined)
|
||||
provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
|
||||
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
|
||||
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
|
||||
for (const [id, item] of configuredProviders(loaded.entries)) {
|
||||
const providerID = id
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.package !== undefined) provider.package = item.package
|
||||
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
|
||||
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
|
||||
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
|
||||
})
|
||||
for (const [id, config] of Object.entries(item.models ?? {})) {
|
||||
catalog.model.update(providerID, id, (model) => {
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
if (config.modelID !== undefined) model.modelID = config.modelID
|
||||
if (config.compatibility !== undefined)
|
||||
model.compatibility = { ...model.compatibility, ...config.compatibility }
|
||||
if (config.package !== undefined) model.package = config.package
|
||||
if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings)
|
||||
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
|
||||
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
|
||||
if (config.capabilities !== undefined) {
|
||||
model.capabilities = {
|
||||
tools: config.capabilities.tools,
|
||||
input: [...config.capabilities.input],
|
||||
output: [...config.capabilities.output],
|
||||
}
|
||||
}
|
||||
if (config.variants !== undefined) {
|
||||
model.variants ??= []
|
||||
for (const variant of config.variants) {
|
||||
let existing = model.variants.find((item) => item.id === variant.id)
|
||||
if (!existing) {
|
||||
existing = { id: variant.id }
|
||||
model.variants.push(existing)
|
||||
}
|
||||
if (variant.settings !== undefined)
|
||||
existing.settings = Provider.mergeOverlay(existing.settings, variant.settings)
|
||||
if (variant.headers !== undefined)
|
||||
existing.headers = Provider.mergeHeaders(existing.headers, variant.headers)
|
||||
if (variant.body !== undefined) existing.body = Provider.mergeOverlay(existing.body, variant.body)
|
||||
}
|
||||
}
|
||||
if (config.cost !== undefined) {
|
||||
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
|
||||
tier: cost.tier && { ...cost.tier },
|
||||
input: cost.input,
|
||||
output: cost.output,
|
||||
cache: {
|
||||
read: cost.cache?.read ?? Money.USDPerMillionTokens.zero,
|
||||
write: cost.cache?.write ?? Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
}))
|
||||
}
|
||||
if (config.disabled !== undefined) model.enabled = !config.disabled
|
||||
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
|
||||
})
|
||||
for (const [id, config] of Object.entries(item.models ?? {})) {
|
||||
catalog.model.update(providerID, id, (model) => {
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
if (config.modelID !== undefined) model.modelID = config.modelID
|
||||
if (config.compatibility !== undefined)
|
||||
model.compatibility = { ...model.compatibility, ...config.compatibility }
|
||||
if (config.package !== undefined) model.package = config.package
|
||||
if (config.settings !== undefined)
|
||||
model.settings = Provider.mergeOverlay(model.settings, config.settings)
|
||||
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
|
||||
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
|
||||
if (config.capabilities !== undefined) {
|
||||
model.capabilities = {
|
||||
tools: config.capabilities.tools,
|
||||
input: [...config.capabilities.input],
|
||||
output: [...config.capabilities.output],
|
||||
}
|
||||
}
|
||||
if (config.variants !== undefined) {
|
||||
model.variants ??= []
|
||||
for (const variant of config.variants) {
|
||||
let existing = model.variants.find((item) => item.id === variant.id)
|
||||
if (!existing) {
|
||||
existing = { id: variant.id }
|
||||
model.variants.push(existing)
|
||||
}
|
||||
if (variant.settings !== undefined)
|
||||
existing.settings = Provider.mergeOverlay(existing.settings, variant.settings)
|
||||
if (variant.headers !== undefined)
|
||||
existing.headers = Provider.mergeHeaders(existing.headers, variant.headers)
|
||||
if (variant.body !== undefined) existing.body = Provider.mergeOverlay(existing.body, variant.body)
|
||||
}
|
||||
}
|
||||
if (config.cost !== undefined) {
|
||||
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
|
||||
tier: cost.tier && { ...cost.tier },
|
||||
input: cost.input,
|
||||
output: cost.output,
|
||||
cache: {
|
||||
read: cost.cache?.read ?? Money.USDPerMillionTokens.zero,
|
||||
write: cost.cache?.write ?? Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
}))
|
||||
}
|
||||
if (config.disabled !== undefined) model.enabled = !config.disabled
|
||||
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -118,3 +106,9 @@ export const Plugin = define({
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function configuredProviders(entries: readonly Config.Entry[]) {
|
||||
return entries
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.flatMap((file) => Object.entries(file.info.providers ?? {}))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as InstructionBuiltIns from "./builtins"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "../location"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { Instructions } from "./index"
|
||||
@@ -15,6 +16,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/In
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
return Service.of({
|
||||
load: (sessionID) =>
|
||||
@@ -31,6 +33,7 @@ const layer = Layer.effect(
|
||||
` Workspace root folder: ${location.project.directory}`,
|
||||
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
|
||||
` Platform: ${process.platform}`,
|
||||
` Use ${global.tmp} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
|
||||
"</env>",
|
||||
].join("\n"),
|
||||
),
|
||||
@@ -58,4 +61,4 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Global.node, Location.node] })
|
||||
|
||||
@@ -146,6 +146,20 @@ export const fromCatalogModel = (
|
||||
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
|
||||
if (credential?.type === "key" && credential.metadata !== undefined)
|
||||
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
|
||||
if (draft.providerID !== Provider.ID.azure) return
|
||||
const configured = draft.settings?.resourceName
|
||||
const resourceName =
|
||||
typeof configured === "string" && configured.trim() !== ""
|
||||
? configured
|
||||
: (process.env.AZURE_RESOURCE_NAME ?? process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME)
|
||||
if (resourceName) draft.settings = { ...draft.settings, resourceName }
|
||||
if (typeof draft.settings?.baseURL !== "string") return
|
||||
draft.settings.baseURL = draft.settings.baseURL
|
||||
.replaceAll("${AZURE_RESOURCE_NAME}", resourceName ?? "${AZURE_RESOURCE_NAME}")
|
||||
.replaceAll(
|
||||
"${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}",
|
||||
resourceName ?? "${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}",
|
||||
)
|
||||
})
|
||||
const packageName = Provider.packageName(resolved.package)
|
||||
const key = apiKey(resolved, credential)
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
export * as AgentPlugin from "./agent"
|
||||
|
||||
import path from "path"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "../agent"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "../location"
|
||||
import { Permission } from "../permission"
|
||||
|
||||
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
|
||||
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
|
||||
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
|
||||
|
||||
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
|
||||
|
||||
Your strengths:
|
||||
@@ -100,38 +93,12 @@ Rules:
|
||||
export const Plugin = define({
|
||||
id: "opencode.agent",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const location = yield* Location.Service
|
||||
const worktree = location.directory
|
||||
const whitelistedDirs = [SHELL_OUTPUT_GLOB, path.join(Global.Path.tmp, "*")]
|
||||
const readonlyExternalDirectory: Permission.Ruleset = [
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
...whitelistedDirs.map(
|
||||
(resource): Permission.Rule => ({ action: "external_directory", resource, effect: "allow" }),
|
||||
),
|
||||
]
|
||||
const defaults: Permission.Ruleset = [
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
...readonlyExternalDirectory,
|
||||
{ action: "question", resource: "*", effect: "deny" },
|
||||
{ action: "plan_enter", resource: "*", effect: "deny" },
|
||||
{ action: "plan_exit", resource: "*", effect: "deny" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "read", resource: "*.env", effect: "ask" },
|
||||
{ action: "read", resource: "*.env.*", effect: "ask" },
|
||||
{ action: "read", resource: "*.env.example", effect: "allow" },
|
||||
]
|
||||
|
||||
yield* ctx.agent.transform((draft) => {
|
||||
draft.update(Agent.defaultID, (item) => {
|
||||
item.name = Agent.Name.make("Build")
|
||||
item.description = "The default agent. Executes tools based on configured permissions."
|
||||
item.mode = "primary"
|
||||
item.permissions.push(
|
||||
...Permission.merge(defaults, [
|
||||
{ action: "question", resource: "*", effect: "allow" },
|
||||
{ action: "plan_enter", resource: "*", effect: "allow" },
|
||||
]),
|
||||
)
|
||||
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
|
||||
})
|
||||
|
||||
draft.update(Agent.ID.make("plan"), (item) => {
|
||||
@@ -139,18 +106,8 @@ export const Plugin = define({
|
||||
item.description = "Plan mode. Disallows all edit tools."
|
||||
item.mode = "primary"
|
||||
item.permissions.push(
|
||||
...Permission.merge(defaults, [
|
||||
{ action: "question", resource: "*", effect: "allow" },
|
||||
{ action: "plan_exit", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: path.join(Global.Path.data, "plans", "*"), effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
{ action: "edit", resource: path.join(".opencode", "plans", "*.md"), effect: "allow" },
|
||||
{
|
||||
action: "edit",
|
||||
resource: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")),
|
||||
effect: "allow",
|
||||
},
|
||||
]),
|
||||
{ action: "question", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -159,10 +116,16 @@ export const Plugin = define({
|
||||
item.description =
|
||||
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
|
||||
item.mode = "subagent"
|
||||
item.permissions.push(...Permission.merge(defaults, [{ action: "subagent", resource: "*", effect: "deny" }]))
|
||||
item.permissions.push(
|
||||
{ action: "question", resource: "*", effect: "deny" },
|
||||
{ action: "subagent", resource: "*", effect: "deny" },
|
||||
)
|
||||
})
|
||||
|
||||
draft.update(Agent.ID.make("explore"), (item) => {
|
||||
const externalDirectories = item.permissions.filter(
|
||||
(rule) => rule.action === "external_directory" && rule.effect === "allow",
|
||||
)
|
||||
item.name = Agent.Name.make("Explore")
|
||||
item.description =
|
||||
'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
|
||||
@@ -170,7 +133,6 @@ export const Plugin = define({
|
||||
item.mode = "subagent"
|
||||
item.permissions.push(
|
||||
...Permission.merge(
|
||||
defaults,
|
||||
[
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
{ action: "grep", resource: "*", effect: "allow" },
|
||||
@@ -178,9 +140,12 @@ export const Plugin = define({
|
||||
{ action: "webfetch", resource: "*", effect: "allow" },
|
||||
{ action: "websearch", resource: "*", effect: "allow" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "read", resource: "*.env", effect: "ask" },
|
||||
{ action: "read", resource: "*.env.*", effect: "ask" },
|
||||
{ action: "read", resource: "*.env.example", effect: "allow" },
|
||||
{ action: "subagent", resource: "*", effect: "deny" },
|
||||
],
|
||||
readonlyExternalDirectory,
|
||||
[{ action: "external_directory", resource: "*", effect: "ask" }, ...externalDirectories],
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -190,7 +155,7 @@ export const Plugin = define({
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_COMPACTION
|
||||
item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
|
||||
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
|
||||
})
|
||||
|
||||
draft.update(Agent.ID.make("title"), (item) => {
|
||||
@@ -198,7 +163,7 @@ export const Plugin = define({
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_TITLE
|
||||
item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
|
||||
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
|
||||
})
|
||||
|
||||
draft.update(Agent.ID.make("summary"), (item) => {
|
||||
@@ -206,7 +171,7 @@ export const Plugin = define({
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_SUMMARY
|
||||
item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
|
||||
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
|
||||
})
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -3,13 +3,14 @@ import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Bus } from "../bus"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { Provider } from "../provider"
|
||||
|
||||
export const ModelsDevPlugin = define({
|
||||
id: "opencode.models-dev",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const bus = yield* Bus.Service
|
||||
const loaded = { data: structuredClone(yield* modelsDev.get()) }
|
||||
const loaded = { data: snapshots(yield* modelsDev.get()) }
|
||||
yield* ctx.integration.transform((integrations) => {
|
||||
for (const provider of loaded.data) {
|
||||
if (provider.environment.length === 0) continue
|
||||
@@ -21,7 +22,13 @@ export const ModelsDevPlugin = define({
|
||||
})
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "env", names: [...provider.environment] },
|
||||
method: {
|
||||
type: "env",
|
||||
names:
|
||||
provider.info.id === Provider.ID.azure
|
||||
? provider.environment.filter((name) => name.endsWith("_API_KEY"))
|
||||
: [...provider.environment],
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -39,7 +46,7 @@ export const ModelsDevPlugin = define({
|
||||
yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
|
||||
Stream.runForEach(() =>
|
||||
modelsDev.get().pipe(
|
||||
Effect.tap((data) => Effect.sync(() => (loaded.data = structuredClone(data)))),
|
||||
Effect.tap((data) => Effect.sync(() => (loaded.data = snapshots(data)))),
|
||||
Effect.andThen(ctx.integration.reload()),
|
||||
Effect.andThen(ctx.catalog.reload()),
|
||||
),
|
||||
@@ -48,3 +55,9 @@ export const ModelsDevPlugin = define({
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function snapshots(data: readonly ModelsDev.Snapshot[]) {
|
||||
return structuredClone(data).filter(
|
||||
(provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as PluginPromise from "./promise"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
|
||||
import type { SessionHooks, SessionHttp, SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session"
|
||||
import type { Info } from "@opencode-ai/plugin/promise/tool"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
@@ -57,6 +58,62 @@ export function fromPromise(plugin: Plugin) {
|
||||
}),
|
||||
)
|
||||
|
||||
function sessionHook<Name extends keyof SessionHooks>(
|
||||
name: Name,
|
||||
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
||||
): Promise<Registration>
|
||||
function sessionHook(
|
||||
...registration: {
|
||||
[Name in keyof SessionHooks]: [
|
||||
name: Name,
|
||||
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
||||
]
|
||||
}[keyof SessionHooks]
|
||||
) {
|
||||
if (registration[0] !== "http")
|
||||
return register(
|
||||
host.session.hook(registration[0], (event) =>
|
||||
Effect.promise(() => Promise.resolve(registration[1](event))),
|
||||
),
|
||||
)
|
||||
return register(
|
||||
host.session.hook("http", (event) => {
|
||||
const middlewares: SessionHttpMiddleware[] = []
|
||||
const output: SessionHttp = {
|
||||
...event,
|
||||
use: (item) => {
|
||||
middlewares.push(item)
|
||||
},
|
||||
}
|
||||
return Effect.promise(() => Promise.resolve(registration[1](output))).pipe(
|
||||
Effect.flatMap(() =>
|
||||
Effect.forEach(
|
||||
middlewares,
|
||||
(item) =>
|
||||
event.use((input, next) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) => {
|
||||
const inputSignal = AbortSignal.any([signal, input.signal])
|
||||
return Promise.resolve(
|
||||
item(new Request(input, { signal: inputSignal }), (request) => {
|
||||
const requestSignal = AbortSignal.any([signal, request.signal])
|
||||
return Effect.runPromiseWith(
|
||||
context,
|
||||
)(next(new Request(request, { signal: requestSignal })), { signal: requestSignal })
|
||||
}),
|
||||
)
|
||||
},
|
||||
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||
}),
|
||||
),
|
||||
{ discard: true },
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const context2: Context = {
|
||||
app: host.app,
|
||||
options: host.options,
|
||||
@@ -194,7 +251,9 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
),
|
||||
refresh:
|
||||
refresh === undefined ? undefined : (credential) => Effect.promise(() => refresh(credential)),
|
||||
refresh === undefined
|
||||
? undefined
|
||||
: (credential) => Effect.promise(() => refresh(credential)),
|
||||
})
|
||||
},
|
||||
remove: draft.method.remove,
|
||||
@@ -263,8 +322,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback) =>
|
||||
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
hook: sessionHook,
|
||||
create: (input) =>
|
||||
run(
|
||||
host.session.create(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AlibabaPlugin } from "./provider/alibaba"
|
||||
import { AmazonBedrockPlugin } from "./provider/amazon-bedrock"
|
||||
import { AnthropicPlugin } from "./provider/anthropic"
|
||||
import { AzureCognitiveServicesPlugin, AzurePlugin } from "./provider/azure"
|
||||
import { AzurePlugin } from "./provider/azure"
|
||||
import { CerebrasPlugin } from "./provider/cerebras"
|
||||
import { CloudflareAIGatewayPlugin } from "./provider/cloudflare-ai-gateway"
|
||||
import { CloudflareWorkersAIPlugin } from "./provider/cloudflare-workers-ai"
|
||||
@@ -12,7 +12,7 @@ import { GatewayPlugin } from "./provider/gateway"
|
||||
import { GithubCopilotPlugin } from "./provider/github-copilot"
|
||||
import { GitLabPlugin } from "./provider/gitlab"
|
||||
import { GooglePlugin } from "./provider/google"
|
||||
import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "./provider/google-vertex"
|
||||
import { GoogleVertexPlugin } from "./provider/google-vertex"
|
||||
import { GroqPlugin } from "./provider/groq"
|
||||
import { KiloPlugin } from "./provider/kilo"
|
||||
import { LLMGatewayPlugin } from "./provider/llmgateway"
|
||||
@@ -36,7 +36,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
AlibabaPlugin,
|
||||
AmazonBedrockPlugin,
|
||||
AnthropicPlugin,
|
||||
AzureCognitiveServicesPlugin,
|
||||
AzurePlugin,
|
||||
CerebrasPlugin,
|
||||
CloudflareAIGatewayPlugin,
|
||||
@@ -47,7 +46,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
GithubCopilotPlugin,
|
||||
GitLabPlugin,
|
||||
GooglePlugin,
|
||||
GoogleVertexAnthropicPlugin,
|
||||
GoogleVertexPlugin,
|
||||
GroqPlugin,
|
||||
KiloPlugin,
|
||||
|
||||
@@ -19,7 +19,9 @@ export const AzurePlugin = define({
|
||||
if (Provider.packageName(item.provider.package) !== "@ai-sdk/azure") continue
|
||||
const configured = item.provider.settings?.resourceName
|
||||
const resourceName =
|
||||
typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
|
||||
typeof configured === "string" && configured.trim() !== ""
|
||||
? configured
|
||||
: (process.env.AZURE_RESOURCE_NAME ?? process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME)
|
||||
if (!resourceName) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.settings = { ...provider.settings, resourceName }
|
||||
@@ -36,9 +38,7 @@ export const AzurePlugin = define({
|
||||
!evt.options.baseURL &&
|
||||
(!Provider.isAISDK(evt.model.package) || typeof evt.model.settings?.baseURL !== "string")
|
||||
) {
|
||||
throw new Error(
|
||||
"AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it",
|
||||
)
|
||||
throw new Error("Azure resource name is missing; set AZURE_RESOURCE_NAME or configure resourceName/baseURL")
|
||||
}
|
||||
}
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/azure"))
|
||||
@@ -58,35 +58,3 @@ export const AzurePlugin = define({
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
export const AzureCognitiveServicesPlugin = define({
|
||||
id: "opencode.provider.azure-cognitive-services",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
|
||||
if (!resourceName) return
|
||||
for (const item of evt.provider.list()) {
|
||||
if (!Provider.isAISDK(item.provider.package)) continue
|
||||
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue
|
||||
if (!item.provider.id.includes("azure-cognitive-services")) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
baseURL: `https://${resourceName}.cognitiveservices.azure.com/openai`,
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== Provider.ID.make("azure-cognitive-services")) return
|
||||
evt.language = selectLanguage(
|
||||
evt.sdk,
|
||||
evt.model.modelID ?? evt.model.id,
|
||||
Boolean(evt.options.useCompletionUrls),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -92,6 +92,22 @@ export const GoogleVertexPlugin = define({
|
||||
evt.options.fetch = authFetch(evt.options.fetch)
|
||||
return
|
||||
}
|
||||
if (evt.package === "@ai-sdk/google-vertex/anthropic") {
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic"))
|
||||
const project = resolveProject(evt.options)
|
||||
const location = String(resolveLocation(evt.options))
|
||||
const regionalBaseURL =
|
||||
(location === "eu" || location === "us") && project && !evt.options.baseURL
|
||||
? `https://aiplatform.${location}.rep.googleapis.com/v1/projects/${project}/locations/${location}/publishers/anthropic/models`
|
||||
: undefined
|
||||
evt.sdk = mod.createVertexAnthropic({
|
||||
...evt.options,
|
||||
project,
|
||||
location,
|
||||
...(regionalBaseURL ? { baseURL: regionalBaseURL } : {}),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (evt.package !== "@ai-sdk/google-vertex") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex"))
|
||||
const project = resolveProject(evt.options)
|
||||
@@ -114,62 +130,3 @@ export const GoogleVertexPlugin = define({
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
export const GoogleVertexAnthropicPlugin = define({
|
||||
id: "opencode.provider.google-vertex-anthropic",
|
||||
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/google-vertex/anthropic") continue
|
||||
const project =
|
||||
item.provider.settings?.project ??
|
||||
process.env.GOOGLE_CLOUD_PROJECT ??
|
||||
process.env.GCP_PROJECT ??
|
||||
process.env.GCLOUD_PROJECT
|
||||
const location =
|
||||
item.provider.settings?.location ??
|
||||
process.env.GOOGLE_CLOUD_LOCATION ??
|
||||
process.env.VERTEX_LOCATION ??
|
||||
"global"
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.settings = { ...provider.settings, ...(project ? { project } : {}), location }
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic"))
|
||||
const project =
|
||||
typeof evt.options.project === "string"
|
||||
? evt.options.project
|
||||
: (process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCP_PROJECT ?? process.env.GCLOUD_PROJECT)
|
||||
const location =
|
||||
typeof evt.options.location === "string"
|
||||
? evt.options.location
|
||||
: (process.env.GOOGLE_CLOUD_LOCATION ?? process.env.VERTEX_LOCATION ?? "global")
|
||||
evt.sdk = mod.createVertexAnthropic({
|
||||
...evt.options,
|
||||
project,
|
||||
location,
|
||||
// Continental multi-regions (eu, us) require Regional Endpoint Platform
|
||||
// domains; the default {region}-aiplatform.googleapis.com does not resolve.
|
||||
...((location === "eu" || location === "us") && project && !evt.options.baseURL
|
||||
? {
|
||||
baseURL: `https://aiplatform.${location}.rep.googleapis.com/v1/projects/${project}/locations/${location}/publishers/anthropic/models`,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== Provider.ID.make("google-vertex-anthropic")) return
|
||||
evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim())
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -225,15 +225,14 @@ export const OpenAIPlugin = define({
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.session.hook("request", (evt) =>
|
||||
Effect.sync(() => {
|
||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
||||
const url = new URL(evt.url)
|
||||
if (url.origin === "https://api.openai.com") {
|
||||
evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`
|
||||
}
|
||||
evt.headers.originator = "opencode"
|
||||
evt.headers["session-id"] = evt.sessionID
|
||||
yield* ctx.session.hook("http", (evt) =>
|
||||
evt.use((request, next) => {
|
||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request)
|
||||
const url = new URL(request.url)
|
||||
request.headers.set("originator", "opencode")
|
||||
request.headers.set("session-id", evt.sessionID)
|
||||
if (url.origin !== "https://api.openai.com") return next(request)
|
||||
return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request))
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@ export * as SessionModelRequest from "./model-request"
|
||||
|
||||
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import type { SessionHttpHandler, SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
|
||||
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "../app"
|
||||
import { Model } from "../model"
|
||||
@@ -48,9 +50,7 @@ interface Prepared {
|
||||
* One request-scoped execution operation. Unknown, hook-removed, and
|
||||
* step-limit-violating calls fail individually through the same seam.
|
||||
*/
|
||||
readonly executeTool: (
|
||||
input: Parameters<Tool.Snapshot["execute"]>[0],
|
||||
) => Effect.Effect<Tool.Result, ExecuteError>
|
||||
readonly executeTool: (input: Parameters<Tool.Snapshot["execute"]>[0]) => Effect.Effect<Tool.Result, ExecuteError>
|
||||
/** True when this request is the final Step; violating calls are rejected and no continuation follows. */
|
||||
readonly stepLimitReached: boolean
|
||||
}
|
||||
@@ -137,8 +137,7 @@ export const boundImages = (messages: LLMRequest["messages"]) => {
|
||||
result: {
|
||||
...part.result,
|
||||
value: part.result.value.map((item: Content) => {
|
||||
if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET)
|
||||
return item
|
||||
if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET) return item
|
||||
removed += Buffer.byteLength(item.uri)
|
||||
return { type: "text" as const, text: IMAGE_REMOVED }
|
||||
}),
|
||||
@@ -189,55 +188,87 @@ export const layer = Layer.effect(
|
||||
.map(SystemPart.make)
|
||||
const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey)
|
||||
const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history
|
||||
const toolDefinitions = tools.definitions
|
||||
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
|
||||
// Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit.
|
||||
const contextEvent = yield* hooks.trigger("session", "context", {
|
||||
const registry = new Map(tools.definitions.map((tool) => [tool.name, tool]))
|
||||
// The definition objects we hand to hooks, mapped back to their tools. Hooks rename a
|
||||
// tool by moving its definition to a new key; recognizing the object recovers the tool.
|
||||
const given = new Map(
|
||||
tools.definitions.map(
|
||||
(tool) => [{ description: tool.description, input: { ...tool.inputSchema } }, tool] as const,
|
||||
),
|
||||
)
|
||||
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
|
||||
const context = yield* hooks.trigger("session", "context", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
system,
|
||||
messages,
|
||||
tools: Object.fromEntries(
|
||||
toolDefinitions.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
|
||||
),
|
||||
})
|
||||
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
|
||||
const registered = toolsByName.get(name)
|
||||
return registered
|
||||
? [{ ...registered, description: tool.description, inputSchema: tool.input }]
|
||||
: []
|
||||
tools: Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])),
|
||||
})
|
||||
// Match each surviving entry back to its tool, by recognizing a moved definition or
|
||||
// by key. Identity wins so a definition moved onto another tool's name still executes
|
||||
// the tool it describes. Entries matching neither were invented by a hook and dropped.
|
||||
// `tool.name` stays canonical so execution can translate renamed calls back.
|
||||
const hooked = new Map(
|
||||
Object.entries(context.tools).flatMap(([name, definition]) => {
|
||||
const tool = given.get(definition) ?? registry.get(name)
|
||||
if (!tool) return []
|
||||
return [[name, { ...tool, description: definition.description, inputSchema: definition.input }] as const]
|
||||
}),
|
||||
)
|
||||
const request = LLM.request({
|
||||
model,
|
||||
http: {
|
||||
headers: SessionModelHeaders.make(session, app),
|
||||
},
|
||||
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
|
||||
system: contextEvent.system,
|
||||
messages: boundImages(unsupportedParts(contextEvent.messages, resolved.capabilities)),
|
||||
tools: hookedTools,
|
||||
system: context.system,
|
||||
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
|
||||
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
const options: StreamOptions = {
|
||||
transform: (request) =>
|
||||
hooks
|
||||
.trigger("session", "request", {
|
||||
http: (request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
let latest = request
|
||||
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
|
||||
const middlewares: SessionHttpMiddleware[] = []
|
||||
const web = yield* HttpClientRequest.toWeb(request)
|
||||
yield* hooks.trigger("session", "http", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
...request,
|
||||
})
|
||||
.pipe(
|
||||
Effect.tap((event) =>
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
request.url = event.url
|
||||
request.headers = event.headers
|
||||
request.body = event.body
|
||||
middlewares.push(item)
|
||||
}),
|
||||
),
|
||||
Effect.asVoid,
|
||||
),
|
||||
})
|
||||
const send = (input: Request) =>
|
||||
Effect.gen(function* () {
|
||||
let sent = HttpClientRequest.fromWeb(input)
|
||||
if (input.body)
|
||||
sent = HttpClientRequest.bodyUint8Array(
|
||||
sent,
|
||||
new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())),
|
||||
input.headers.get("content-type") ?? undefined,
|
||||
)
|
||||
latest = sent
|
||||
const response = yield* handler(sent)
|
||||
const body = [204, 205, 304].includes(response.status)
|
||||
? null
|
||||
: yield* Stream.toReadableStreamEffect(response.stream)
|
||||
const output = new Response(body, { status: response.status, headers: response.headers })
|
||||
origins.set(output, sent)
|
||||
return output
|
||||
})
|
||||
const dispatch = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
send,
|
||||
)
|
||||
const response = yield* dispatch(web)
|
||||
const origin = origins.get(response) ?? latest
|
||||
return HttpClientResponse.fromWeb(origin, response)
|
||||
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||
}
|
||||
if (promptCacheSnapshots) {
|
||||
const current = PromptCacheDiagnostics.snapshot(request)
|
||||
@@ -256,13 +287,15 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
}
|
||||
const executeTool: Prepared["executeTool"] = (executeInput) => {
|
||||
const executeTool: Prepared["executeTool"] = (input) => {
|
||||
if (stepLimitReached)
|
||||
return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
|
||||
if (toolsByName.has(executeInput.call.name) && !Object.hasOwn(contextEvent.tools, executeInput.call.name))
|
||||
return new Tool.Error({ message: `Tool is not available for this request: ${executeInput.call.name}` })
|
||||
const tool = hooked.get(input.call.name)
|
||||
// A registered tool absent from the hooked set was removed or renamed by a hook.
|
||||
if (!tool && registry.has(input.call.name))
|
||||
return new Tool.Error({ message: `Tool is not available for this request: ${input.call.name}` })
|
||||
return tools
|
||||
.execute(executeInput)
|
||||
.execute(tool ? { ...input, call: { ...input.call, name: tool.name } } : input)
|
||||
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -23,6 +23,10 @@ export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavaila
|
||||
{ providerID: Provider.ID, modelID: ID },
|
||||
) {
|
||||
override get message() {
|
||||
if (this.providerID === "azure-cognitive-services")
|
||||
return `Model unavailable: ${this.providerID}/${this.modelID}. This provider has been deprecated; use azure/${this.modelID} instead.`
|
||||
if (this.providerID === "google-vertex-anthropic")
|
||||
return `Model unavailable: ${this.providerID}/${this.modelID}. This provider has been deprecated; use google-vertex/${this.modelID} instead.`
|
||||
return `Model unavailable: ${this.providerID}/${this.modelID}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,13 +94,13 @@ function experimental(info: typeof ConfigV1.Info.Type) {
|
||||
{ action: "provider.use" as const, resource: "*", effect: "deny" as const },
|
||||
...info.enabled_providers.map((resource) => ({
|
||||
action: "provider.use" as const,
|
||||
resource,
|
||||
resource: providerID(resource),
|
||||
effect: "allow" as const,
|
||||
})),
|
||||
]),
|
||||
...(info.disabled_providers ?? []).map((resource) => ({
|
||||
action: "provider.use" as const,
|
||||
resource,
|
||||
resource: providerID(resource),
|
||||
effect: "deny" as const,
|
||||
})),
|
||||
]
|
||||
@@ -188,7 +188,7 @@ function modelSelection(input?: string, variant?: string) {
|
||||
if (input === undefined || !/^[^/#]+\/[^#]+$/.test(input)) return undefined
|
||||
const separator = input.indexOf("/")
|
||||
return {
|
||||
providerID: input.slice(0, separator),
|
||||
providerID: providerID(input.slice(0, separator)),
|
||||
model: input.slice(separator + 1),
|
||||
...(variant === undefined || variant.length === 0 || variant.includes("#") ? {} : { variant }),
|
||||
}
|
||||
@@ -234,24 +234,57 @@ function migrateMcp(info: ConfigMCPV1.Info) {
|
||||
|
||||
function providers(info?: Readonly<Record<string, ConfigProviderV1.Info>>) {
|
||||
if (!info) return undefined
|
||||
return Object.fromEntries(Object.entries(info).map(([name, provider]) => [name, migrateProvider(provider)]))
|
||||
return Object.fromEntries(
|
||||
Object.entries(info).flatMap(([name, provider]) => {
|
||||
const id = providerID(name)
|
||||
// If both names are present, keep the settings under the current name and ignore the old one.
|
||||
if (id !== name && info[id]) return []
|
||||
return [[id, migrateProvider(name, provider)]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function migrateProvider(info: ConfigProviderV1.Info) {
|
||||
function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
|
||||
const options = ConfigProviderOptionsV1.provider(info.options ?? {})
|
||||
const vertexAnthropic = sourceID === "google-vertex-anthropic"
|
||||
const legacyAzure = sourceID === "azure-cognitive-services"
|
||||
const packageName = info.npm ? Provider.aisdk(info.npm) : undefined
|
||||
const modelPackage = vertexAnthropic ? (packageName ?? Provider.aisdk("@ai-sdk/google-vertex/anthropic")) : undefined
|
||||
const legacyAzureBaseURL =
|
||||
legacyAzure && info.npm === "@ai-sdk/openai-compatible" && !info.api
|
||||
? "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai"
|
||||
: undefined
|
||||
return {
|
||||
name: info.name,
|
||||
env: info.env,
|
||||
package: info.npm ? Provider.aisdk(info.npm) : undefined,
|
||||
settings: info.api ? { ...options.settings, baseURL: info.api } : options.settings,
|
||||
env: legacyAzure ? info.env?.filter((name) => name !== "AZURE_COGNITIVE_SERVICES_RESOURCE_NAME") : info.env,
|
||||
// The current Google Vertex provider includes Gemini and Claude. Keep the Anthropic SDK on Claude models
|
||||
// instead of changing the package inherited by every model on the provider.
|
||||
package: vertexAnthropic ? undefined : packageName,
|
||||
settings: info.api
|
||||
? { ...options.settings, baseURL: info.api }
|
||||
: legacyAzureBaseURL
|
||||
? { ...options.settings, baseURL: legacyAzureBaseURL }
|
||||
: options.settings,
|
||||
headers: info.options && options.headers,
|
||||
body: info.options && options.body,
|
||||
models:
|
||||
info.models &&
|
||||
Object.fromEntries(Object.entries(info.models).map(([name, model]) => [name, migrateModel(model)])),
|
||||
Object.fromEntries(
|
||||
Object.entries(info.models).map(([name, model]) => {
|
||||
const migrated = migrateModel(model)
|
||||
return [name, modelPackage && !migrated.package ? { ...migrated, package: modelPackage } : migrated]
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Rename these only in files detected as V1 by a field that exists only in the old config format.
|
||||
function providerID(input: string) {
|
||||
if (input === "azure-cognitive-services") return "azure"
|
||||
if (input === "google-vertex-anthropic") return "google-vertex"
|
||||
return input
|
||||
}
|
||||
|
||||
function migrateModel(info: typeof ConfigProviderV1.Model.Type) {
|
||||
const settings = info.options && ConfigProviderOptionsV1.model(info.options)
|
||||
const costs = info.cost && [
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as Vcs from "./vcs"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "./location"
|
||||
@@ -10,13 +10,14 @@ import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { VcsGit } from "./vcs/git"
|
||||
import { VcsHg } from "./vcs/hg"
|
||||
|
||||
export { FileStatus, Mode }
|
||||
export { FileStatus, Info, Mode }
|
||||
|
||||
export interface DiffOptions {
|
||||
readonly context?: number
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly info: () => Effect.Effect<Info>
|
||||
readonly status: () => Effect.Effect<FileStatus[]>
|
||||
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[]>
|
||||
}
|
||||
@@ -40,6 +41,10 @@ const layer = Layer.effect(
|
||||
const location = yield* Location.Service
|
||||
const impl = adapter(proc, fs, location)
|
||||
return Service.of({
|
||||
info: Effect.fn("Vcs.info")(function* () {
|
||||
if (!impl) return { branch: {} }
|
||||
return yield* impl.info()
|
||||
}),
|
||||
status: Effect.fn("Vcs.status")(function* () {
|
||||
if (!impl) return []
|
||||
return yield* impl.status()
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as VcsGit from "./git"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import type { DiffOptions, Interface } from "../vcs"
|
||||
import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./patch"
|
||||
@@ -20,6 +20,12 @@ export function make(proc: AppProcess.Interface, input: { directory: string; wor
|
||||
const ctx: Ctx = { git: makeGit(proc), directory: input.directory, worktree: input.worktree }
|
||||
|
||||
return {
|
||||
info: Effect.fn("VcsGit.info")(function* () {
|
||||
const [current, root] = yield* Effect.all([ctx.git.branch(ctx.directory), ctx.git.defaultBranch(ctx.directory)], {
|
||||
concurrency: 2,
|
||||
})
|
||||
return { branch: { current, default: root?.name } } satisfies Info
|
||||
}),
|
||||
status: Effect.fn("VcsGit.status")(function* () {
|
||||
const git = ctx.git
|
||||
const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import type { DiffOptions, Interface } from "../vcs"
|
||||
@@ -73,6 +73,9 @@ export function make(
|
||||
})
|
||||
|
||||
return {
|
||||
info: Effect.fn("VcsHg.info")(function* () {
|
||||
return { branch: { current: yield* hg.branch(), default: "default" } } satisfies Info
|
||||
}),
|
||||
status: Effect.fn("VcsHg.status")(function* () {
|
||||
const [items, batch] = yield* Effect.all(
|
||||
// Zero-context patches are enough to count changed lines.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
@@ -9,15 +10,19 @@ import { Location } from "@opencode-ai/core/location"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { agentHost, host } from "./plugin/host"
|
||||
|
||||
const testLocation = location({ directory: AbsolutePath.make("/project") })
|
||||
const locationLayer = Layer.succeed(Location.Service, Location.Service.of(testLocation))
|
||||
const global = Global.make({ data: "/data", config: "/config", tmp: "/tmp/opencode" })
|
||||
const globalLayer = Layer.succeed(Global.Service, Global.Service.of(global))
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, Location.node]), [
|
||||
[Global.node, globalLayer],
|
||||
[Location.node, locationLayer],
|
||||
]) as unknown as Layer.Layer<unknown, never>,
|
||||
)
|
||||
@@ -120,25 +125,35 @@ describe("Agent", () => {
|
||||
const id = Agent.ID.make("custom")
|
||||
|
||||
yield* agent.transform((editor) => editor.update(id, () => {}))
|
||||
expect(yield* agent.get(id)).toEqual(Agent.Info.default(id))
|
||||
const info = yield* agent.get(id)
|
||||
expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual(
|
||||
Agent.Info.default(id).permissions,
|
||||
)
|
||||
expect(Permission.evaluate("external_directory", path.join(global.data, "shell", "*", "*"), info?.permissions ?? []).effect).toBe(
|
||||
"allow",
|
||||
)
|
||||
expect(Permission.evaluate("external_directory", path.join(global.data, "tool-output", "*"), info?.permissions ?? []).effect).toBe(
|
||||
"allow",
|
||||
)
|
||||
expect(Permission.evaluate("external_directory", path.join(global.config, "*"), info?.permissions ?? []).effect).toBe(
|
||||
"allow",
|
||||
)
|
||||
expect(Permission.evaluate("external_directory", path.join(global.tmp, "*"), info?.permissions ?? []).effect).toBe(
|
||||
"allow",
|
||||
)
|
||||
|
||||
yield* agent.transform((editor) => editor.remove(id))
|
||||
expect(yield* agent.get(id)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not ambiently opt built-in agents into bash", () =>
|
||||
it.effect("applies managed external directories without opting built-in agents into bash", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
yield* AgentPlugin.Plugin.effect(
|
||||
host({
|
||||
agent: agentHost(agent),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
|
||||
),
|
||||
)
|
||||
|
||||
const agents = yield* agent.list()
|
||||
@@ -152,6 +167,20 @@ describe("Agent", () => {
|
||||
"title",
|
||||
])
|
||||
expect((yield* agent.get(Agent.defaultID))?.system).toBeUndefined()
|
||||
const permissions = (yield* agent.get(Agent.defaultID))?.permissions ?? []
|
||||
expect(
|
||||
Permission.evaluate("external_directory", path.join(global.data, "shell", "*", "*"), permissions).effect,
|
||||
).toBe("allow")
|
||||
expect(
|
||||
Permission.evaluate("external_directory", path.join(global.data, "tool-output", "*"), permissions).effect,
|
||||
).toBe("allow")
|
||||
expect(Permission.evaluate("external_directory", path.join(global.config, "*"), permissions).effect).toBe("allow")
|
||||
expect(Permission.evaluate("external_directory", path.join(global.tmp, "*"), permissions).effect).toBe("allow")
|
||||
const explore = yield* agent.get(Agent.ID.make("explore"))
|
||||
expect(Permission.evaluate("read", ".env", explore?.permissions ?? []).effect).toBe("ask")
|
||||
expect(Permission.evaluate("read", ".env.local", explore?.permissions ?? []).effect).toBe("ask")
|
||||
expect(Permission.evaluate("read", ".env.example", explore?.permissions ?? []).effect).toBe("allow")
|
||||
expect(Permission.evaluate("read", "src/index.ts", explore?.permissions ?? []).effect).toBe("allow")
|
||||
for (const item of agents) {
|
||||
expect(item.permissions.some((rule) => rule.action === "bash" && rule.effect !== "deny")).toBe(false)
|
||||
}
|
||||
@@ -165,11 +194,6 @@ describe("Agent", () => {
|
||||
host({
|
||||
agent: agentHost(agent),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
|
||||
),
|
||||
)
|
||||
|
||||
yield* Effect.forEach(["general", "explore"], (id) =>
|
||||
|
||||
@@ -16,6 +16,31 @@ describe("AISDKNative", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("maps Azure deployments and settings to native routes", () => {
|
||||
const settings = {
|
||||
apiKey: "secret",
|
||||
resourceName: "resource",
|
||||
apiVersion: "2025-01-01-preview",
|
||||
queryParams: { feature: "enabled" },
|
||||
useDeploymentBasedUrls: true,
|
||||
reasoningEffort: "high",
|
||||
}
|
||||
expect(map("@ai-sdk/azure", settings, "deployment")).toEqual({
|
||||
package: "@opencode-ai/ai/providers/azure/responses",
|
||||
settings: {
|
||||
apiKey: "secret",
|
||||
resourceName: "resource",
|
||||
apiVersion: "2025-01-01-preview",
|
||||
queryParams: { feature: "enabled" },
|
||||
useDeploymentBasedUrls: true,
|
||||
providerOptions: { openai: { reasoningEffort: "high" } },
|
||||
},
|
||||
})
|
||||
expect(map("@ai-sdk/azure", { ...settings, useCompletionUrls: true }, "custom-deployment")?.package).toBe(
|
||||
"@opencode-ai/ai/providers/azure/chat",
|
||||
)
|
||||
})
|
||||
|
||||
test("maps Bedrock provider and request options", () => {
|
||||
expect(
|
||||
map(
|
||||
|
||||
@@ -20,10 +20,13 @@ import { agentHost, host } from "../plugin/host"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, FSUtil.node, Global.node])))
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const defaultPermissions = [
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
] satisfies Permission.Ruleset
|
||||
const defaultPermissions = (global: Global.Interface): Permission.Ruleset => [
|
||||
...Agent.Info.default(Agent.ID.make("test")).permissions,
|
||||
{ action: "external_directory", resource: path.join(global.data, "shell", "*", "*"), effect: "allow" },
|
||||
{ action: "external_directory", resource: path.join(global.data, "tool-output", "*"), effect: "allow" },
|
||||
{ action: "external_directory", resource: path.join(global.tmp, "*"), effect: "allow" },
|
||||
{ action: "external_directory", resource: path.join(global.config, "*"), effect: "allow" },
|
||||
]
|
||||
|
||||
test("rejects named agent color tokens", () => {
|
||||
expect(() => decode({ agents: { reviewer: { color: "warning" } } })).toThrow()
|
||||
@@ -58,6 +61,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
it.effect("applies all global permissions before agent-specific permissions", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const global = yield* Global.Service
|
||||
const build = Agent.ID.make("build")
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(build, (agent) => {
|
||||
@@ -110,7 +114,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
const buildAgent = yield* agents.get(build)
|
||||
if (!buildAgent) throw new Error("expected configured build agent")
|
||||
expect(buildAgent.permissions).toEqual([
|
||||
...defaultPermissions,
|
||||
...defaultPermissions(global),
|
||||
{ action: "bash", resource: "*", effect: "allow" },
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
@@ -128,7 +132,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
|
||||
})
|
||||
expect(reviewer.permissions).toEqual([
|
||||
...defaultPermissions,
|
||||
...defaultPermissions(global),
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
@@ -136,7 +140,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
])
|
||||
expect(Permission.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
|
||||
expect((yield* agents.get(Agent.ID.make("late")))?.permissions).toEqual([
|
||||
...defaultPermissions,
|
||||
...defaultPermissions(global),
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "allow" },
|
||||
@@ -270,6 +274,7 @@ Use native v2 fields.`,
|
||||
await fs.writeFile(path.join(tmp.path, "modes", "plan.md"), "Make a plan.")
|
||||
})
|
||||
const agents = yield* Agent.Service
|
||||
const global = yield* Global.Service
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
@@ -287,13 +292,13 @@ Use native v2 fields.`,
|
||||
system: "Review carefully.",
|
||||
description: "Markdown description",
|
||||
request: { body: { temperature: 0.5 } },
|
||||
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
|
||||
permissions: [...defaultPermissions(global), { action: "edit", resource: "*", effect: "deny" }],
|
||||
})
|
||||
expect(yield* agents.get(Agent.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
|
||||
expect(yield* agents.get(Agent.ID.make("native"))).toMatchObject({
|
||||
system: "Use native v2 fields.",
|
||||
request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
|
||||
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
|
||||
permissions: [...defaultPermissions(global), { action: "edit", resource: "*", effect: "deny" }],
|
||||
})
|
||||
expect(yield* agents.get(Agent.ID.make("disabled"))).toBeUndefined()
|
||||
expect(yield* agents.get(Agent.ID.make("empty"))).toBeUndefined()
|
||||
|
||||
@@ -475,6 +475,111 @@ describe("Config", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("renames old provider IDs while migrating v1 configuration", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
model: "azure-cognitive-services/deployment",
|
||||
enabled_providers: ["google-vertex-anthropic"],
|
||||
disabled_providers: ["azure-cognitive-services"],
|
||||
agent: {
|
||||
reviewer: { model: "google-vertex-anthropic/claude-sonnet" },
|
||||
},
|
||||
command: {
|
||||
review: { template: "Review", model: "azure-cognitive-services/deployment" },
|
||||
},
|
||||
provider: {
|
||||
"azure-cognitive-services": {
|
||||
npm: "@ai-sdk/azure",
|
||||
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
models: { deployment: {} },
|
||||
},
|
||||
"google-vertex-anthropic": {
|
||||
npm: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { project: "test-project", location: "us-central1" },
|
||||
models: { "claude-sonnet": {} },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" })
|
||||
expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" })
|
||||
expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" })
|
||||
expect(migrated.experimental?.policies).toEqual([
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "google-vertex", effect: "allow" },
|
||||
{ action: "provider.use", resource: "azure", effect: "deny" },
|
||||
])
|
||||
expect(migrated.providers?.azure).toMatchObject({
|
||||
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
package: Provider.aisdk("@ai-sdk/azure"),
|
||||
models: { deployment: {} },
|
||||
})
|
||||
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
|
||||
expect(migrated.providers?.["google-vertex"]).toMatchObject({
|
||||
package: undefined,
|
||||
settings: { project: "test-project", location: "us-central1" },
|
||||
models: {
|
||||
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
|
||||
},
|
||||
})
|
||||
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
"azure-cognitive-services": {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.azure).toMatchObject({
|
||||
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: {
|
||||
baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores old provider IDs when the current provider ID is configured", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
azure: { models: { current: {} } },
|
||||
"azure-cognitive-services": { models: { legacy: {} } },
|
||||
"google-vertex": { models: { gemini: {} } },
|
||||
"google-vertex-anthropic": { models: { claude: {} } },
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() })
|
||||
expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves the built-in package for v1 Vertex Anthropic custom models", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
"google-vertex-anthropic": {
|
||||
models: { claude: {} },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined()
|
||||
expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe(
|
||||
Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("migrates v1 interleaved fields to compatibility", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
|
||||
@@ -5,8 +5,8 @@ import path from "path"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
describe("global paths", () => {
|
||||
test("tmp path is under the system temp directory", () => {
|
||||
expect(Global.Path.tmp).toBe(path.join(os.tmpdir(), "opencode"))
|
||||
test("tmp path is the canonical system temp directory", async () => {
|
||||
expect(Global.Path.tmp).toBe(await fs.realpath(path.join(os.tmpdir(), "opencode")))
|
||||
expect(Global.make().tmp).toBe(Global.Path.tmp)
|
||||
})
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ const locationLayer = Layer.succeed(
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(InstructionBuiltIns.node, [
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ config: "/global" })],
|
||||
[Global.node, Global.layerWith({ config: "/global", tmp: "/temporary" })],
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -49,6 +49,7 @@ describe("InstructionBuiltIns", () => {
|
||||
` Workspace root folder: ${projectDirectory}`,
|
||||
" Is directory a git repo: yes",
|
||||
` Platform: ${process.platform}`,
|
||||
" Use /temporary for temporary work outside the workspace; it already exists and is pre-approved for external directory access.",
|
||||
"</env>",
|
||||
"",
|
||||
`Today's date: ${localDate(timestamp)}`,
|
||||
|
||||
@@ -666,6 +666,50 @@ describe("LocationServiceMap", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("explains replacements for unavailable legacy provider models", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
for (const [providerID, replacement] of [
|
||||
["azure-cognitive-services", "azure"],
|
||||
["google-vertex-anthropic", "google-vertex"],
|
||||
] as const) {
|
||||
const failure = yield* SessionRunnerModel.Service.use((models) =>
|
||||
models.resolve(
|
||||
Session.Info.make({
|
||||
id: Session.ID.make(`ses_removed_${providerID}`),
|
||||
projectID: Project.ID.global,
|
||||
title: "test",
|
||||
model: {
|
||||
id: Model.ID.make("chat"),
|
||||
providerID: Provider.ID.make(providerID),
|
||||
},
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location,
|
||||
}),
|
||||
),
|
||||
).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.ModelUnavailableError",
|
||||
providerID,
|
||||
modelID: "chat",
|
||||
})
|
||||
expect(failure.message).toBe(
|
||||
`Model unavailable: ${providerID}/chat. This provider has been deprecated; use ${replacement}/chat instead.`,
|
||||
)
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves the selected catalog identity when the package model id differs", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
interface ModelOptions {
|
||||
readonly providerID?: Provider.ID
|
||||
readonly modelID?: string
|
||||
readonly compatibility?: Compatibility
|
||||
readonly settings?: Info["settings"]
|
||||
@@ -25,7 +26,7 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
|
||||
Info.make({
|
||||
id: ID.make("test-model"),
|
||||
modelID: ID.make(options.modelID ?? "api-test-model"),
|
||||
providerID: Provider.ID.make("test-provider"),
|
||||
providerID: options.providerID ?? Provider.ID.make("test-provider"),
|
||||
name: "Test model",
|
||||
compatibility: options.compatibility,
|
||||
package: packageName,
|
||||
@@ -42,6 +43,65 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
|
||||
})
|
||||
|
||||
describe("ModelResolver", () => {
|
||||
it.effect("constructs native Azure requests with deployment IDs and resolved resource URLs", () =>
|
||||
Effect.gen(function* () {
|
||||
const responses = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/azure"), {
|
||||
providerID: Provider.ID.azure,
|
||||
modelID: "responses-deployment",
|
||||
settings: { resourceName: "modern-resource", apiVersion: "2025-01-01-preview" },
|
||||
}),
|
||||
Credential.Key.make({ type: "key", key: "secret" }),
|
||||
)
|
||||
const chat = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/azure"), {
|
||||
providerID: Provider.ID.azure,
|
||||
modelID: "chat-deployment",
|
||||
settings: { resourceName: "modern-resource", useCompletionUrls: true },
|
||||
}),
|
||||
)
|
||||
const deployment = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/azure"), {
|
||||
providerID: Provider.ID.azure,
|
||||
modelID: "legacy-url-deployment",
|
||||
settings: {
|
||||
resourceName: "modern-resource",
|
||||
apiVersion: "2025-01-01-preview",
|
||||
useDeploymentBasedUrls: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const compatible = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
|
||||
providerID: Provider.ID.azure,
|
||||
modelID: "legacy-deployment",
|
||||
settings: {
|
||||
resourceName: "legacy-resource",
|
||||
baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(responses).toMatchObject({ id: "responses-deployment", provider: "azure" })
|
||||
expect(responses.route).toMatchObject({
|
||||
id: "azure-openai-responses",
|
||||
endpoint: {
|
||||
baseURL: "https://modern-resource.openai.azure.com/openai/v1",
|
||||
query: { "api-version": "2025-01-01-preview" },
|
||||
},
|
||||
})
|
||||
expect(chat).toMatchObject({ id: "chat-deployment", provider: "azure" })
|
||||
expect(chat.route.id).toBe("azure-openai-chat")
|
||||
expect(deployment).toMatchObject({ id: "legacy-url-deployment", provider: "azure" })
|
||||
expect(deployment.route.endpoint).toMatchObject({
|
||||
baseURL: "https://modern-resource.openai.azure.com/openai/deployments/legacy-url-deployment",
|
||||
query: { "api-version": "2025-01-01-preview" },
|
||||
})
|
||||
expect(compatible).toMatchObject({ id: "legacy-deployment", provider: "azure" })
|
||||
expect(compatible.route.endpoint.baseURL).toBe("https://legacy-resource.cognitiveservices.azure.com/openai")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps Bedrock Mantle models to native Responses and safeguards to Chat", () =>
|
||||
Effect.gen(function* () {
|
||||
const credential = Credential.Key.make({ type: "key", key: "secret" })
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Location } from "@opencode-ai/core/location"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
@@ -220,6 +221,61 @@ describe("ModelsDevPlugin", () => {
|
||||
}).pipe(Effect.provide(models(path.join(import.meta.dir, "fixtures", "models-dev.json")))),
|
||||
)
|
||||
|
||||
it.effect("omits legacy provider aliases", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const snapshots = [
|
||||
["azure", "Azure", "AZURE_API_KEY", "@ai-sdk/azure"],
|
||||
["azure-cognitive-services", "Azure Cognitive Services", "AZURE_COGNITIVE_SERVICES_API_KEY", "@ai-sdk/azure"],
|
||||
["google-vertex", "Google Vertex", "GOOGLE_APPLICATION_CREDENTIALS", "@ai-sdk/google-vertex"],
|
||||
[
|
||||
"google-vertex-anthropic",
|
||||
"Google Vertex Anthropic",
|
||||
"GOOGLE_APPLICATION_CREDENTIALS",
|
||||
"@ai-sdk/google-vertex/anthropic",
|
||||
],
|
||||
].map(([id, name, environment, packageName]) => ({
|
||||
info: {
|
||||
id: Provider.ID.make(id),
|
||||
name,
|
||||
package: Provider.aisdk(packageName),
|
||||
},
|
||||
environment: id === "azure" ? ["AZURE_RESOURCE_NAME", environment] : [environment],
|
||||
models: [],
|
||||
})) satisfies readonly ModelsDev.Snapshot[]
|
||||
|
||||
yield* ModelsDevPlugin.effect(
|
||||
host({
|
||||
catalog: catalogHost(catalog),
|
||||
integration: integrationHost(integrations),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
ModelsDev.Service,
|
||||
ModelsDev.Service.of({
|
||||
get: () => Effect.succeed(snapshots),
|
||||
refresh: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* catalog.provider.get(Provider.ID.azure)).toBeDefined()
|
||||
expect(yield* catalog.provider.get(Provider.ID.make("google-vertex"))).toBeDefined()
|
||||
expect(yield* catalog.provider.get(Provider.ID.make("azure-cognitive-services"))).toBeUndefined()
|
||||
expect(yield* catalog.provider.get(Provider.ID.make("google-vertex-anthropic"))).toBeUndefined()
|
||||
expect(yield* integrations.get(Integration.ID.make("azure"))).toBeDefined()
|
||||
expect(yield* integrations.get(Integration.ID.make("azure"))).toMatchObject({
|
||||
methods: [{ type: "key" }, { type: "env", names: ["AZURE_API_KEY"] }],
|
||||
})
|
||||
expect(yield* integrations.get(Integration.ID.make("google-vertex"))).toBeDefined()
|
||||
expect(yield* integrations.get(Integration.ID.make("azure-cognitive-services"))).toBeUndefined()
|
||||
expect(yield* integrations.get(Integration.ID.make("google-vertex-anthropic"))).toBeUndefined()
|
||||
expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.azure-cognitive-services")
|
||||
expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.google-vertex-anthropic")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("converts reasoning options into settings variants", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
@@ -389,10 +445,7 @@ describe("ModelsDevPlugin", () => {
|
||||
},
|
||||
])
|
||||
|
||||
const openrouter = yield* catalog.model.get(
|
||||
Provider.ID.make("openrouter"),
|
||||
Model.ID.make("openrouter-toggle"),
|
||||
)
|
||||
const openrouter = yield* catalog.model.get(Provider.ID.make("openrouter"), Model.ID.make("openrouter-toggle"))
|
||||
expect(openrouter?.variants).toEqual([
|
||||
{ id: Model.VariantID.make("none"), settings: { reasoning: { enabled: false } } },
|
||||
{ id: Model.VariantID.make("thinking"), settings: { reasoning: { enabled: true } } },
|
||||
@@ -414,10 +467,7 @@ describe("ModelsDevPlugin", () => {
|
||||
},
|
||||
])
|
||||
|
||||
const vertex = yield* catalog.model.get(
|
||||
Provider.ID.make("google-vertex"),
|
||||
Model.ID.make("gemini-2.5-flash-lite"),
|
||||
)
|
||||
const vertex = yield* catalog.model.get(Provider.ID.make("google-vertex"), Model.ID.make("gemini-2.5-flash-lite"))
|
||||
expect(vertex?.variants).toEqual([
|
||||
{
|
||||
id: Model.VariantID.make("none"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
@@ -15,7 +15,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import type { SessionHooks, SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
import { host as testHost } from "./host"
|
||||
@@ -148,7 +148,9 @@ describe("fromPromise", () => {
|
||||
expect((await ctx.agent.get({ agentID: Agent.ID.make("reviewer") })).data).toMatchObject({
|
||||
description: "Reviews code",
|
||||
})
|
||||
await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow("Agent not found: missing")
|
||||
await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow(
|
||||
"Agent not found: missing",
|
||||
)
|
||||
const models = (await ctx.catalog.model.list()).data
|
||||
expect(models.find((model) => model.providerID === "test" && model.id === "alias")).toMatchObject({
|
||||
modelID: "gpt-5",
|
||||
@@ -221,6 +223,105 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adapts promise session HTTP hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const bodies: string[] = []
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-session-http",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use(async (request, next) => {
|
||||
request.headers.set("x-hook", "promise")
|
||||
await next(request)
|
||||
const response = await next(request)
|
||||
return new Response(`${await response.text()}-response`)
|
||||
})
|
||||
})
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use(async (request, next) => {
|
||||
const response = await next(request)
|
||||
return new Response(`${await response.text()}-outer`)
|
||||
})
|
||||
})
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||
const event: PluginHooks.Domains["session"]["http"] = {
|
||||
sessionID: Session.ID.make("ses_promise_session_http"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
}
|
||||
|
||||
yield* hooks.trigger("session", "http", event)
|
||||
const request = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
(input: Request) =>
|
||||
Effect.promise(() => input.text()).pipe(
|
||||
Effect.tap((body) => Effect.sync(() => bodies.push(body))),
|
||||
Effect.as(new Response(input.headers.get("x-hook") ?? "missing")),
|
||||
),
|
||||
)
|
||||
const response = yield* request(new Request("https://provider.test", { method: "POST", body: "payload" }))
|
||||
|
||||
expect(bodies).toEqual(["payload", "payload"])
|
||||
expect(yield* Effect.promise(() => response.text())).toBe("promise-response-outer")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-session-http-interrupt",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use((request, next) => next(request))
|
||||
})
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
const started = yield* Deferred.make<void>()
|
||||
const interrupted = yield* Deferred.make<void>()
|
||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||
const event: PluginHooks.Domains["session"]["http"] = {
|
||||
sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
}
|
||||
|
||||
yield* hooks.trigger("session", "http", event)
|
||||
const request = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
() =>
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Effect.never),
|
||||
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
|
||||
),
|
||||
)
|
||||
const fiber = yield* request(new Request("https://provider.test")).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
|
||||
expect(yield* Deferred.isDone(interrupted)).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disposes a hook registration on request", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
@@ -315,19 +416,17 @@ describe("fromPromise", () => {
|
||||
id: "promise-tool",
|
||||
setup: async (ctx) => {
|
||||
await ctx.tool.transform((tools) => {
|
||||
tools.add(
|
||||
{
|
||||
name: "hello",
|
||||
options: { codemode: false },
|
||||
description: "Hello",
|
||||
input: Schema.Struct({ name: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: async ({ name }, context) => {
|
||||
await context.progress({ phase: "greeting" })
|
||||
return { output: `Hello, ${name}!` }
|
||||
},
|
||||
tools.add({
|
||||
name: "hello",
|
||||
options: { codemode: false },
|
||||
description: "Hello",
|
||||
input: Schema.Struct({ name: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: async ({ name }, context) => {
|
||||
await context.progress({ phase: "greeting" })
|
||||
return { output: `Hello, ${name}!` }
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { describe, expect } from "bun:test"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* AzureCognitiveServicesPlugin.effect(host)
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
}
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
fx,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
}
|
||||
return {
|
||||
responses: make("responses"),
|
||||
messages: make("messages"),
|
||||
chat: make("chat"),
|
||||
languageModel: make("languageModel"),
|
||||
}
|
||||
}
|
||||
|
||||
describe("AzureCognitiveServicesPlugin", () => {
|
||||
it.effect("maps the resource env var to the Azure SDK baseURL", () =>
|
||||
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(Provider.ID.make("azure-cognitive-services"), (item) => {
|
||||
item.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
const result = required(yield* catalog.provider.get(Provider.ID.make("azure-cognitive-services")))
|
||||
expect(result).toMatchObject({
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: { baseURL: "https://cognitive.cognitiveservices.azure.com/openai" },
|
||||
})
|
||||
expect(result.settings?.resourceName).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("leaves baseURL unset without resource env and ignores other providers", () =>
|
||||
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const azure = Provider.Info.make({
|
||||
...Provider.Info.empty(Provider.ID.make("azure-cognitive-services")),
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
})
|
||||
const openai = Provider.Info.make({
|
||||
...Provider.Info.empty(Provider.ID.openai),
|
||||
package: "aisdk:test-provider",
|
||||
})
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.package = azure.package
|
||||
item.package = azure.package
|
||||
})
|
||||
catalog.provider.update(openai.id, (item) => {
|
||||
item.package = openai.package
|
||||
item.package = openai.package
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
const azure = required(yield* catalog.provider.get(Provider.ID.make("azure-cognitive-services")))
|
||||
const openai = required(yield* catalog.provider.get(Provider.ID.openai))
|
||||
expect(azure.settings?.baseURL).toBeUndefined()
|
||||
expect(azure).toMatchObject({ package: "aisdk:@ai-sdk/openai-compatible" })
|
||||
expect(openai.settings?.baseURL).toBeUndefined()
|
||||
expect(openai).toMatchObject({ package: "aisdk:test-provider" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("selects chat only for completion URLs", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("azure-cognitive-services"), Model.ID.make("deployment")),
|
||||
modelID: Model.ID.make("deployment"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { useCompletionUrls: true },
|
||||
})
|
||||
expect(calls).toEqual(["chat:deployment"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the legacy Azure selector order and provider guard", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("azure-cognitive-services"), Model.ID.make("deployment")),
|
||||
modelID: Model.ID.make("deployment"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
})
|
||||
const ignored = yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.openai, Model.ID.make("deployment")),
|
||||
modelID: Model.ID.make("deployment"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
})
|
||||
expect(calls).toEqual(["responses:deployment"])
|
||||
expect(ignored.language).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back from responses to messages, chat, then languageModel", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
const sdk = fakeSelectorSdk(calls)
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("azure-cognitive-services"), Model.ID.make("messages-deployment")),
|
||||
modelID: Model.ID.make("messages-deployment"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel },
|
||||
options: {},
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("azure-cognitive-services"), Model.ID.make("chat-deployment")),
|
||||
modelID: Model.ID.make("chat-deployment"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: { chat: sdk.chat, languageModel: sdk.languageModel },
|
||||
options: {},
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("azure-cognitive-services"), Model.ID.make("language-deployment")),
|
||||
modelID: Model.ID.make("language-deployment"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: { languageModel: sdk.languageModel },
|
||||
options: {},
|
||||
})
|
||||
expect(calls).toEqual([
|
||||
"messages:messages-deployment",
|
||||
"chat:chat-deployment",
|
||||
"languageModel:language-deployment",
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -75,6 +75,21 @@ describe("AzurePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("resolves resourceName from the legacy env", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "legacy-resource" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(Provider.ID.azure, (item) => {
|
||||
item.package = Provider.aisdk("@ai-sdk/azure")
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.azure)).settings?.resourceName).toBe("legacy-resource")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("keeps explicit resourceName over env and ignores other providers", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* (definition: typeof GoogleVertexAnthropicPlugin | typeof GoogleVertexPlugin) {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* definition.effect(host)
|
||||
})
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function selector(calls: string[]) {
|
||||
return (id: string) => {
|
||||
calls.push(`languageModel:${id}`)
|
||||
return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
}
|
||||
}
|
||||
|
||||
describe("GoogleVertexAnthropicPlugin", () => {
|
||||
it.effect("resolves legacy project and location env on provider update", () =>
|
||||
withEnv(
|
||||
{
|
||||
GOOGLE_CLOUD_PROJECT: "cloud-project",
|
||||
GCP_PROJECT: "gcp-project",
|
||||
GCLOUD_PROJECT: "gcloud-project",
|
||||
GOOGLE_CLOUD_LOCATION: "cloud-location",
|
||||
VERTEX_LOCATION: "vertex-location",
|
||||
GOOGLE_VERTEX_LOCATION: "google-vertex-location",
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(Provider.ID.make("google-vertex-anthropic"), (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/google-vertex/anthropic")
|
||||
}),
|
||||
)
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
expect((yield* catalog.provider.get(Provider.ID.make("google-vertex-anthropic")))?.settings?.project).toBe(
|
||||
"cloud-project",
|
||||
)
|
||||
expect((yield* catalog.provider.get(Provider.ID.make("google-vertex-anthropic")))?.settings?.location).toBe(
|
||||
"cloud-location",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("keeps configured project and location over env fallback", () =>
|
||||
withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(Provider.ID.make("google-vertex-anthropic"), (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/google-vertex/anthropic")
|
||||
provider.settings = { ...provider.settings, project: "configured-project", location: "configured-location" }
|
||||
}),
|
||||
)
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
expect((yield* catalog.provider.get(Provider.ID.make("google-vertex-anthropic")))?.settings?.project).toBe(
|
||||
"configured-project",
|
||||
)
|
||||
expect((yield* catalog.provider.get(Provider.ID.make("google-vertex-anthropic")))?.settings?.location).toBe(
|
||||
"configured-location",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("creates SDKs from legacy env fallback and default location", () =>
|
||||
withEnv(
|
||||
{
|
||||
GOOGLE_CLOUD_PROJECT: undefined,
|
||||
GCP_PROJECT: "gcp-project",
|
||||
GCLOUD_PROJECT: "gcloud-project",
|
||||
GOOGLE_CLOUD_LOCATION: undefined,
|
||||
VERTEX_LOCATION: undefined,
|
||||
GOOGLE_VERTEX_LOCATION: "ignored-location",
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(
|
||||
Provider.ID.make("google-vertex-anthropic"),
|
||||
Model.ID.make("claude-sonnet-4-5"),
|
||||
),
|
||||
modelID: Model.ID.make("claude-sonnet-4-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { name: "google-vertex-anthropic" },
|
||||
})
|
||||
expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe(
|
||||
"https://aiplatform.googleapis.com/v1/projects/gcp-project/locations/global/publishers/anthropic/models",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses GOOGLE_CLOUD_LOCATION before VERTEX_LOCATION when creating SDKs", () =>
|
||||
withEnv(
|
||||
{ GOOGLE_CLOUD_PROJECT: "project", GOOGLE_CLOUD_LOCATION: "cloud-location", VERTEX_LOCATION: "vertex-location" },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(
|
||||
Provider.ID.make("google-vertex-anthropic"),
|
||||
Model.ID.make("claude-sonnet-4-5"),
|
||||
),
|
||||
modelID: Model.ID.make("claude-sonnet-4-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { name: "google-vertex-anthropic" },
|
||||
})
|
||||
expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe(
|
||||
"https://cloud-location-aiplatform.googleapis.com/v1/projects/project/locations/cloud-location/publishers/anthropic/models",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make("claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("claude-sonnet-4-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { name: "google-vertex", project: "project", location: "eu" },
|
||||
})
|
||||
expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe(
|
||||
"https://aiplatform.eu.rep.googleapis.com/v1/projects/project/locations/eu/publishers/anthropic/models",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps configured baseURL for google-vertex Anthropic models", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make("claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("claude-sonnet-4-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" },
|
||||
})
|
||||
expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe("https://proxy.example/v1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("selects google-vertex Anthropic language models through plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin(GoogleVertexPlugin)
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
const sdkResult = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make(" claude-sonnet-4-5 ")),
|
||||
modelID: Model.ID.make(" claude-sonnet-4-5 "),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { name: "google-vertex", project: "project", location: "us" },
|
||||
})
|
||||
const languageResult = yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make(" claude-sonnet-4-5 ")),
|
||||
modelID: Model.ID.make(" claude-sonnet-4-5 "),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: sdkResult.sdk,
|
||||
options: {},
|
||||
})
|
||||
const language = languageResult.language as unknown as { config: { baseURL: string }; modelId: string }
|
||||
expect(language.config.baseURL).toBe(
|
||||
"https://aiplatform.us.rep.googleapis.com/v1/projects/project/locations/us/publishers/anthropic/models",
|
||||
)
|
||||
expect(language.modelId).toBe("claude-sonnet-4-5")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("trims model IDs before selecting language models", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("google-vertex-anthropic"), Model.ID.make(" claude-sonnet-4-5 ")),
|
||||
modelID: Model.ID.make(" claude-sonnet-4-5 "),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: { languageModel: selector(calls) },
|
||||
options: {},
|
||||
})
|
||||
expect(calls).toEqual(["languageModel:claude-sonnet-4-5"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores non Vertex Anthropic providers for language selection", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
const result = yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make("claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("claude-sonnet-4-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: { languageModel: selector(calls) },
|
||||
options: {},
|
||||
})
|
||||
expect(calls).toEqual([])
|
||||
expect(result.language).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -309,6 +309,27 @@ describe("GoogleVertexPlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("creates Anthropic SDKs for canonical Google Vertex models", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.googleVertex, Model.ID.make("claude-sonnet-4-6@default")),
|
||||
modelID: Model.ID.make("claude-sonnet-4-6@default"),
|
||||
package: "aisdk:@ai-sdk/google-vertex/anthropic",
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { name: "google-vertex", project: "project", location: "eu" },
|
||||
})
|
||||
|
||||
expect(result.sdk.languageModel("claude-sonnet-4-6@default").config.baseURL).toBe(
|
||||
"https://aiplatform.eu.rep.googleapis.com/v1/projects/project/locations/eu/publishers/anthropic/models",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps Google auth fetch for OpenAI-compatible Vertex endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
googleAuthOptions.length = 0
|
||||
|
||||
@@ -12,6 +12,7 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import type { SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -29,6 +30,29 @@ function required<T>(value: T | undefined): T {
|
||||
return value
|
||||
}
|
||||
|
||||
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
|
||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||
yield* (yield* PluginHooks.Service).trigger("session", "http", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
})
|
||||
const request = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
(input: Request) => {
|
||||
const headers = new Headers(input.headers)
|
||||
headers.set("x-seen-url", input.url)
|
||||
return Effect.succeed(new Response(null, { headers }))
|
||||
},
|
||||
)
|
||||
const response = yield* request(new Request(url, { method: "POST", body: "{}" }))
|
||||
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
|
||||
})
|
||||
|
||||
describe("OpenAIPlugin", () => {
|
||||
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -100,33 +124,9 @@ describe("OpenAIPlugin", () => {
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
|
||||
url: "https://api.openai.com/v1/responses",
|
||||
method: "POST",
|
||||
headers: {},
|
||||
body: "{}",
|
||||
})
|
||||
const custom = yield* (yield* PluginHooks.Service).trigger("session", "request", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("custom-openai"), id: Model.ID.make("gpt-5.5") }),
|
||||
url: "https://custom.example/v1/responses",
|
||||
method: "POST",
|
||||
headers: {},
|
||||
body: "{}",
|
||||
})
|
||||
const proxy = yield* (yield* PluginHooks.Service).trigger("session", "request", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
|
||||
url: "https://proxy.example/v1/responses?region=us",
|
||||
method: "POST",
|
||||
headers: {},
|
||||
body: "{}",
|
||||
})
|
||||
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
|
||||
const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses")
|
||||
const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us")
|
||||
|
||||
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
|
||||
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
@@ -134,7 +134,7 @@ describe("OpenAIPlugin", () => {
|
||||
expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
|
||||
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
|
||||
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
||||
expect(custom.headers).toEqual({})
|
||||
expect(custom.headers).not.toHaveProperty("originator")
|
||||
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
|
||||
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
||||
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||
@@ -184,21 +184,13 @@ describe("OpenAIPlugin", () => {
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
|
||||
url: "https://api.openai.com/v1/responses",
|
||||
method: "POST",
|
||||
headers: {},
|
||||
body: "{}",
|
||||
})
|
||||
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
|
||||
|
||||
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
expect(model.enabled).toBe(true)
|
||||
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
|
||||
expect(request.headers).toEqual({})
|
||||
expect(request.headers).not.toHaveProperty("originator")
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -41,6 +41,7 @@ import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import path from "node:path"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { agentHost, catalogHost, host } from "./plugin/host"
|
||||
@@ -104,37 +105,39 @@ const promptCatalog = Layer.mock(Catalog.Service, {
|
||||
small: () => Effect.succeed(undefined),
|
||||
},
|
||||
})
|
||||
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
[SessionRunnerModel.node, models],
|
||||
[InstructionBuiltIns.node, systemContext],
|
||||
[InstructionDiscovery.node, instructionContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillInstructions.node, skillInstructions],
|
||||
[ReferenceInstructions.node, referenceInstructions],
|
||||
[McpInstructions.node, mcpInstructions],
|
||||
[Config.node, config],
|
||||
[Permission.node, permission],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
])
|
||||
const execution = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
Effect.gen(function* () {
|
||||
const sessionRunner = yield* SessionRunner.Service
|
||||
const coordinator = yield* SessionRunCoordinator.make<Session.ID, SessionRunner.RunError>({
|
||||
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }),
|
||||
})
|
||||
return SessionExecution.Service.of({
|
||||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
interrupt: coordinator.interrupt,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(runnerLayer))
|
||||
const it = testEffect(
|
||||
const runnerLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
||||
AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[LayerNodePlatform.llmClient, llmClient],
|
||||
[SessionRunnerModel.node, models],
|
||||
[InstructionBuiltIns.node, systemContext],
|
||||
[InstructionDiscovery.node, instructionContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillInstructions.node, skillInstructions],
|
||||
[ReferenceInstructions.node, referenceInstructions],
|
||||
[McpInstructions.node, mcpInstructions],
|
||||
[Config.node, config],
|
||||
[Permission.node, permission],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
])
|
||||
const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
||||
Layer.effect(
|
||||
SessionExecution.Service,
|
||||
Effect.gen(function* () {
|
||||
const sessionRunner = yield* SessionRunner.Service
|
||||
const coordinator = yield* SessionRunCoordinator.make<Session.ID, SessionRunner.RunError>({
|
||||
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }),
|
||||
})
|
||||
return SessionExecution.Service.of({
|
||||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
interrupt: coordinator.interrupt,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(runnerLayer(llmClient)))
|
||||
const testLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
@@ -156,7 +159,7 @@ const it = testEffect(
|
||||
Session.node,
|
||||
]),
|
||||
[
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
[LayerNodePlatform.llmClient, llmClient],
|
||||
[Permission.node, permission],
|
||||
[Catalog.node, promptCatalog],
|
||||
[SessionRunnerModel.node, models],
|
||||
@@ -168,10 +171,10 @@ const it = testEffect(
|
||||
[Config.node, config],
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
[SessionExecution.node, execution],
|
||||
[SessionExecution.node, execution(llmClient)],
|
||||
],
|
||||
),
|
||||
)
|
||||
)
|
||||
const it = testEffect(testLayer(client))
|
||||
const sessionID = Session.ID.make("ses_runner_recorded")
|
||||
|
||||
describe("SessionRunnerLLM recorded", () => {
|
||||
@@ -247,3 +250,90 @@ describe("SessionRunnerLLM recorded", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("SessionModelRequest HTTP bridge", () => {
|
||||
const bodies: Uint8Array[] = []
|
||||
const methods: string[] = []
|
||||
const response = [
|
||||
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
|
||||
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
||||
"data: [DONE]",
|
||||
"",
|
||||
].join("\n\n")
|
||||
const transport = Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.sync(() => {
|
||||
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
||||
methods.push(request.method)
|
||||
bodies.push(request.body.body.slice())
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(response, { headers: { "content-type": "text/event-stream" } }),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
const retryIt = testEffect(
|
||||
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
|
||||
)
|
||||
|
||||
retryIt.effect("lets an Effect plugin send the same POST Request twice", () =>
|
||||
Effect.gen(function* () {
|
||||
bodies.length = 0
|
||||
methods.length = 0
|
||||
const agents = yield* Agent.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(Agent.ID.make("build"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.permissions.push({ action: "execute", resource: "*", effect: "deny" })
|
||||
}),
|
||||
)
|
||||
const pluginHost = host({
|
||||
agent: agentHost(agents),
|
||||
catalog: catalogHost(catalog),
|
||||
session: { hook: (name, callback) => hooks.register("session", name, callback) },
|
||||
})
|
||||
yield* pluginHost.session.hook("http", (event) =>
|
||||
event.use((request, next) =>
|
||||
Effect.gen(function* () {
|
||||
yield* next(request).pipe(Effect.flatMap((response) => Effect.promise(() => response.text())))
|
||||
return yield* next(request)
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const retrySessionID = Session.ID.make("ses_model_request_http_retry")
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: retrySessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const session = yield* Session.Service
|
||||
yield* session.prompt({ sessionID: retrySessionID, text: "Say hello.", resume: false })
|
||||
|
||||
yield* session.resume(retrySessionID)
|
||||
|
||||
expect(methods).toEqual(["POST", "POST"])
|
||||
expect(bodies).toHaveLength(2)
|
||||
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
|
||||
expect(bodies[1]).toEqual(bodies[0])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -887,6 +887,27 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("executes a tool renamed by a session context hook", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.tools.renamed_echo = event.tools.echo!
|
||||
delete event.tools.echo
|
||||
}),
|
||||
)
|
||||
yield* admit(session, "Use the renamed tool")
|
||||
yield* TestLLM.push(TestLLM.tool("call-renamed", "renamed_echo", { text: "renamed" }), [])
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("renamed_echo")
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo")
|
||||
expect(executions).toEqual(["renamed"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("advertises and executes a location registered tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -160,6 +160,7 @@ describeHg("Vcs mercurial", () => {
|
||||
await commitAll(directory, "feature change")
|
||||
})
|
||||
const diff = yield* vcs.diff("branch")
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "default" } })
|
||||
expect(diff.map((item) => ({ file: item.file, status: item.status }))).toEqual([
|
||||
{ file: "file.txt", status: "modified" },
|
||||
])
|
||||
|
||||
@@ -53,6 +53,7 @@ describe("Vcs", () => {
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.info()).toEqual({ branch: {} })
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
expect(yield* vcs.diff("working")).toEqual([])
|
||||
expect(yield* vcs.diff("branch")).toEqual([])
|
||||
@@ -163,6 +164,7 @@ describe("Vcs", () => {
|
||||
await commitAll(directory, "feature change")
|
||||
})
|
||||
const diff = yield* vcs.diff("branch")
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
|
||||
expect(diff.map((item) => ({ file: item.file, status: item.status }))).toEqual([
|
||||
{ file: "file.txt", status: "modified" },
|
||||
])
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { SessionApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { HttpRequest } from "@opencode-ai/ai/route"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { JsonSchema } from "effect"
|
||||
import type { Effect, JsonSchema } from "effect"
|
||||
import type { Hooks } from "./registration.js"
|
||||
|
||||
export interface SessionContext {
|
||||
@@ -16,15 +15,23 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionRequest extends HttpRequest {
|
||||
export interface SessionHttp {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly use: (middleware: SessionHttpMiddleware) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export type SessionHttpHandler = (request: Request) => Effect.Effect<Response, Error>
|
||||
|
||||
export type SessionHttpMiddleware = (
|
||||
request: Request,
|
||||
next: SessionHttpHandler,
|
||||
) => Effect.Effect<Response, Error>
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly request: SessionRequest
|
||||
readonly http: SessionHttp
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { SessionApi } from "@opencode-ai/client/promise/api"
|
||||
import type { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { HttpRequest } from "@opencode-ai/ai/route"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
@@ -16,15 +15,23 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionRequest extends HttpRequest {
|
||||
export interface SessionHttp {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly use: (middleware: SessionHttpMiddleware) => void
|
||||
}
|
||||
|
||||
export type SessionHttpHandler = (request: Request) => Promise<Response>
|
||||
|
||||
export type SessionHttpMiddleware = (
|
||||
request: Request,
|
||||
next: SessionHttpHandler,
|
||||
) => Promise<Response> | Response
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly request: SessionRequest
|
||||
readonly http: SessionHttp
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
@@ -851,6 +851,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"agent": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -11301,6 +11311,104 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/vcs": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"vcs"
|
||||
],
|
||||
"operationId": "v2.vcs.get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Info"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Vcs.Info"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"location",
|
||||
"data"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Get current and default branch information for the requested location.",
|
||||
"summary": "VCS info"
|
||||
}
|
||||
},
|
||||
"/api/vcs/status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -12032,11 +12140,15 @@
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"directory"
|
||||
"directory",
|
||||
"canonical"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -12498,7 +12610,6 @@
|
||||
"cost",
|
||||
"tokens",
|
||||
"time",
|
||||
"title",
|
||||
"location"
|
||||
],
|
||||
"additionalProperties": false
|
||||
@@ -13837,6 +13948,15 @@
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"allOf": [
|
||||
{
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -20765,7 +20885,7 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"worktree": {
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
"vcs": {
|
||||
@@ -20792,7 +20912,7 @@
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"worktree",
|
||||
"canonical",
|
||||
"time",
|
||||
"sandboxes"
|
||||
],
|
||||
@@ -20806,11 +20926,15 @@
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"directory"
|
||||
"directory",
|
||||
"canonical"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -22445,7 +22569,6 @@
|
||||
"slug",
|
||||
"projectID",
|
||||
"directory",
|
||||
"title",
|
||||
"version",
|
||||
"time"
|
||||
],
|
||||
@@ -29127,6 +29250,30 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Vcs.Branch": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"current": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Vcs.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branch": {
|
||||
"$ref": "#/components/schemas/Vcs.Branch"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"branch"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Vcs.FileStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -13,6 +13,20 @@ const DiffQuery = Schema.Struct({
|
||||
})
|
||||
|
||||
export const VcsGroup = HttpApiGroup.make("server.vcs")
|
||||
.add(
|
||||
HttpApiEndpoint.get("vcs.get", "/api/vcs", {
|
||||
query: LocationQuery,
|
||||
success: Location.response(Vcs.Info),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.vcs.get",
|
||||
summary: "VCS info",
|
||||
description: "Get current and default branch information for the requested location.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("vcs.status", "/api/vcs/status", {
|
||||
query: LocationQuery,
|
||||
|
||||
@@ -46,6 +46,9 @@ export const Info = Schema.Struct({
|
||||
permissions: [
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*.env", effect: "ask" },
|
||||
{ action: "read", resource: "*.env.*", effect: "ask" },
|
||||
{ action: "read", resource: "*.env.example", effect: "allow" },
|
||||
],
|
||||
}) satisfies Info,
|
||||
})),
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
export * as Vcs from "./vcs.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "./schema.js"
|
||||
import { NonNegativeInt, optional } from "./schema.js"
|
||||
|
||||
export const Branch = Schema.Struct({
|
||||
current: optional(Schema.String),
|
||||
default: optional(Schema.String),
|
||||
}).annotate({ identifier: "Vcs.Branch" })
|
||||
export interface Branch extends Schema.Schema.Type<typeof Branch> {}
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
branch: Branch,
|
||||
}).annotate({ identifier: "Vcs.Info" })
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const Mode = Schema.Literals(["working", "branch"]).annotate({ identifier: "Vcs.Mode" })
|
||||
export type Mode = typeof Mode.Type
|
||||
|
||||
@@ -16,6 +16,7 @@ import { FileDiff } from "../src/file-diff.js"
|
||||
import { Money } from "../src/money.js"
|
||||
import { Skill } from "../src/skill.js"
|
||||
import { Shell } from "../src/shell.js"
|
||||
import { Vcs } from "../src/vcs.js"
|
||||
import { PersistedRevert } from "../src/session-revert.js"
|
||||
import { AbsolutePath, optional } from "../src/schema.js"
|
||||
|
||||
@@ -134,6 +135,10 @@ describe("contract hygiene", () => {
|
||||
expect(Pty.ID.create()).toStartWith("pty_")
|
||||
})
|
||||
|
||||
test("VCS info omits unavailable branch names", () => {
|
||||
expect(Schema.encodeSync(Vcs.Info)({ branch: { current: undefined, default: undefined } })).toEqual({ branch: {} })
|
||||
})
|
||||
|
||||
test("reusable public identifiers are stable and unique", () => {
|
||||
const identifiers = [
|
||||
Agent.Color,
|
||||
@@ -166,6 +171,8 @@ describe("contract hygiene", () => {
|
||||
SessionPending.SyntheticData,
|
||||
SessionPending.User,
|
||||
SessionPending.Synthetic,
|
||||
Vcs.Branch,
|
||||
Vcs.Info,
|
||||
].map((schema) => schema.ast.annotations?.identifier)
|
||||
|
||||
expect(identifiers.every((identifier) => typeof identifier === "string")).toBe(true)
|
||||
|
||||
@@ -7,6 +7,14 @@ import { response } from "../location"
|
||||
export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
return handlers
|
||||
.handle("vcs.get", () =>
|
||||
response(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.info()
|
||||
}),
|
||||
),
|
||||
)
|
||||
.handle("vcs.status", () =>
|
||||
response(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -125,7 +125,6 @@ type ToolName =
|
||||
| "webfetch"
|
||||
| "websearch"
|
||||
| "skill"
|
||||
| "plan_exit"
|
||||
|
||||
type ToolRule = {
|
||||
view: ToolView
|
||||
@@ -516,15 +515,6 @@ function runLsp(p: ToolProps): ToolInline {
|
||||
}
|
||||
}
|
||||
|
||||
function runPlanExit(p: ToolProps): ToolInline {
|
||||
return {
|
||||
icon: "→",
|
||||
title: "Switching to build agent",
|
||||
mode: "block",
|
||||
body: p.frame.status === "completed" ? p.frame.output : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function patchTitle(file: PatchFile, directory?: string): string {
|
||||
if (file.status === "added") {
|
||||
return `# Created ${toolPath(file.file, { directory })}`
|
||||
@@ -1077,16 +1067,6 @@ const TOOL_RULES = {
|
||||
start: scrollSkillStart,
|
||||
},
|
||||
},
|
||||
plan_exit: {
|
||||
view: {
|
||||
output: true,
|
||||
final: false,
|
||||
},
|
||||
run: runPlanExit,
|
||||
scroll: {
|
||||
start: () => "",
|
||||
},
|
||||
},
|
||||
} as const satisfies ToolRegistry
|
||||
|
||||
function key(name: string): name is ToolName {
|
||||
|
||||
@@ -13,6 +13,8 @@ const config = path.join(xdgConfig!, app)
|
||||
const state = path.join(xdgState!, app)
|
||||
const tmp = path.join(os.tmpdir(), app)
|
||||
|
||||
await fs.mkdir(tmp, { recursive: true })
|
||||
|
||||
const paths = {
|
||||
get home() {
|
||||
return process.env.OPENCODE_TEST_HOME ?? os.homedir()
|
||||
@@ -24,7 +26,7 @@ const paths = {
|
||||
cache,
|
||||
config,
|
||||
state,
|
||||
tmp,
|
||||
tmp: await fs.realpath(tmp),
|
||||
}
|
||||
|
||||
export const Path = paths
|
||||
@@ -35,7 +37,6 @@ await Promise.all([
|
||||
fs.mkdir(Path.data, { recursive: true }),
|
||||
fs.mkdir(Path.config, { recursive: true }),
|
||||
fs.mkdir(Path.state, { recursive: true }),
|
||||
fs.mkdir(Path.tmp, { recursive: true }),
|
||||
fs.mkdir(Path.log, { recursive: true }),
|
||||
fs.mkdir(Path.bin, { recursive: true }),
|
||||
fs.mkdir(Path.repos, { recursive: true }),
|
||||
|
||||
@@ -81,8 +81,7 @@ current built-in actions use these resources:
|
||||
| `<server>_<tool>` | `*` for an MCP tool; unsupported characters in both names become `_` |
|
||||
| `execute` | `*`; controls availability of the Code Mode dispatcher, while each nested tool still enforces its own permission |
|
||||
|
||||
Built-in agent policy also reserves `plan_enter` and `plan_exit` for plan-mode
|
||||
transitions. `doom_loop` and `lsp` are not current V2 Core permission actions.
|
||||
`doom_loop` and `lsp` are not current V2 Core permission actions.
|
||||
|
||||
## External directories
|
||||
|
||||
@@ -132,7 +131,9 @@ matching, so authorize only trusted directory boundaries.
|
||||
|
||||
## Defaults
|
||||
|
||||
The evaluator's fallback is `ask`, but shipped agents include ordered defaults:
|
||||
Every agent, including custom agents, starts with ordered defaults that allow
|
||||
tools, ask for external directories, ask for `.env` reads, and allow
|
||||
`.env.example` reads. Shipped agents then add their own policies:
|
||||
|
||||
| Agent | Effective default policy |
|
||||
| --- | --- |
|
||||
@@ -153,8 +154,12 @@ The base read rules are ordered as follows:
|
||||
]
|
||||
```
|
||||
|
||||
OpenCode also permits its managed tool-output and temporary directories where
|
||||
needed. These exceptions do not grant general external-directory access.
|
||||
OpenCode also permits its managed tool-output, shell-output, temporary, and
|
||||
global configuration directories. These exceptions apply only to the
|
||||
external-directory boundary for every agent; the underlying action still uses
|
||||
its own permission rules. The environment instructions identify the temporary
|
||||
directory available for work outside the workspace. Later global and
|
||||
agent-specific rules can override these defaults.
|
||||
|
||||
## Agent overrides
|
||||
|
||||
|
||||
+18
-5
@@ -239,16 +239,29 @@ without restarting OpenCode.
|
||||
|
||||
### Runtime hooks
|
||||
|
||||
Runtime hooks intercept live operations. Their event objects expose specific
|
||||
mutable fields:
|
||||
Runtime hooks intercept live operations:
|
||||
|
||||
| Hook | Mutable fields |
|
||||
| ------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
|
||||
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
|
||||
| `ctx.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
|
||||
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
|
||||
| `ctx.session.hook("http", callback)` | `use`, registering request and response handling |
|
||||
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
|
||||
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
|
||||
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
|
||||
|
||||
HTTP hooks can modify requests, inspect responses, retry, or return a
|
||||
response without calling the provider. It applies to native models; AI SDK
|
||||
models do not currently pass through this hook.
|
||||
|
||||
```ts
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use((request, next) => {
|
||||
request.headers.set("x-session-id", event.sessionID)
|
||||
return next(request)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
For example, remove a tool from selected model requests and normalize another
|
||||
tool's input:
|
||||
@@ -259,7 +272,7 @@ import { Plugin } from "@opencode-ai/plugin"
|
||||
export default Plugin.define({
|
||||
id: "acme.guards",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("request", (event) => {
|
||||
await ctx.session.hook("context", (event) => {
|
||||
delete event.tools.write
|
||||
})
|
||||
|
||||
|
||||
+153
-6
@@ -851,6 +851,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"agent": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -11301,6 +11311,104 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/vcs": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"vcs"
|
||||
],
|
||||
"operationId": "v2.vcs.get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Info"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Vcs.Info"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"location",
|
||||
"data"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Get current and default branch information for the requested location.",
|
||||
"summary": "VCS info"
|
||||
}
|
||||
},
|
||||
"/api/vcs/status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -12032,11 +12140,15 @@
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"directory"
|
||||
"directory",
|
||||
"canonical"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -12498,7 +12610,6 @@
|
||||
"cost",
|
||||
"tokens",
|
||||
"time",
|
||||
"title",
|
||||
"location"
|
||||
],
|
||||
"additionalProperties": false
|
||||
@@ -13837,6 +13948,15 @@
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"allOf": [
|
||||
{
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -20765,7 +20885,7 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"worktree": {
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
"vcs": {
|
||||
@@ -20792,7 +20912,7 @@
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"worktree",
|
||||
"canonical",
|
||||
"time",
|
||||
"sandboxes"
|
||||
],
|
||||
@@ -20806,11 +20926,15 @@
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"directory"
|
||||
"directory",
|
||||
"canonical"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -22445,7 +22569,6 @@
|
||||
"slug",
|
||||
"projectID",
|
||||
"directory",
|
||||
"title",
|
||||
"version",
|
||||
"time"
|
||||
],
|
||||
@@ -29127,6 +29250,30 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Vcs.Branch": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"current": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Vcs.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branch": {
|
||||
"$ref": "#/components/schemas/Vcs.Branch"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"branch"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Vcs.FileStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -851,6 +851,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"agent": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -11301,6 +11311,104 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/vcs": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"vcs"
|
||||
],
|
||||
"operationId": "v2.vcs.get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Info"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Vcs.Info"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"location",
|
||||
"data"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Get current and default branch information for the requested location.",
|
||||
"summary": "VCS info"
|
||||
}
|
||||
},
|
||||
"/api/vcs/status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -12032,11 +12140,15 @@
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"directory"
|
||||
"directory",
|
||||
"canonical"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -12498,7 +12610,6 @@
|
||||
"cost",
|
||||
"tokens",
|
||||
"time",
|
||||
"title",
|
||||
"location"
|
||||
],
|
||||
"additionalProperties": false
|
||||
@@ -13837,6 +13948,15 @@
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"allOf": [
|
||||
{
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -20765,7 +20885,7 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"worktree": {
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
"vcs": {
|
||||
@@ -20792,7 +20912,7 @@
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"worktree",
|
||||
"canonical",
|
||||
"time",
|
||||
"sandboxes"
|
||||
],
|
||||
@@ -20806,11 +20926,15 @@
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"directory"
|
||||
"directory",
|
||||
"canonical"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -22445,7 +22569,6 @@
|
||||
"slug",
|
||||
"projectID",
|
||||
"directory",
|
||||
"title",
|
||||
"version",
|
||||
"time"
|
||||
],
|
||||
@@ -29127,6 +29250,30 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Vcs.Branch": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"current": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Vcs.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branch": {
|
||||
"$ref": "#/components/schemas/Vcs.Branch"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"branch"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Vcs.FileStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user