feat(core): add location-scoped config loading (#29625)

This commit is contained in:
Dax
2026-05-30 00:06:08 -04:00
committed by GitHub
parent 5fb85a6aa3
commit 9583e08be4
89 changed files with 3507 additions and 525 deletions

View File

@@ -3,6 +3,7 @@ import { Effect, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { effectCmd } from "../../effect-cmd"
export const V2Command = effectCmd({
@@ -37,7 +38,7 @@ export const V2Command = effectCmd({
Effect.withSpan("Cli.debug.v2"),
Effect.provide(
LocationServiceMap.get({
directory: process.cwd(),
directory: AbsolutePath.make(process.cwd()),
}),
),
Effect.provide(LocationServiceMap.layer),

View File

@@ -43,6 +43,7 @@ import { ConfigSkills } from "./skills"
import { ConfigVariable } from "./variable"
import { Npm } from "@opencode-ai/core/npm"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { ConfigExperimental } from "@opencode-ai/core/config/experimental"
const log = Log.create({ service: "config" })
@@ -301,6 +302,9 @@ export const Info = Schema.Struct({
mcp_timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in milliseconds for model context protocol (MCP) requests",
}),
policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({
description: "Policy statements applied to supported resources, such as provider access",
}),
}),
),
}).annotate({ identifier: "Config" })

View File

@@ -1,6 +1,7 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect, Layer, Schema } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
@@ -40,7 +41,9 @@ export class V2LocationMiddleware extends HttpApiMiddleware.Service<
function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
const query = new URL(request.url, "http://localhost").searchParams
return {
directory: query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
directory: AbsolutePath.make(
query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
),
workspaceID: query.get("location[workspace]") || request.headers["x-opencode-workspace"],
}
}

View File

@@ -166,7 +166,7 @@ export const layer = Layer.effect(
? {
id: ModelV2.ID.make(row.model.id),
providerID: ProviderV2.ID.make(row.model.providerID),
variant: ModelV2.VariantID.make(row.model.variant ?? "default"),
variant: row.model.variant ? ModelV2.VariantID.make(row.model.variant) : undefined,
}
: undefined,
cost: row.cost,

View File

@@ -3,9 +3,8 @@ import { createServer, type Server } from "node:http"
import { streamText } from "ai"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { disposeAllInstances, provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { reply, TestLLMServer } from "../lib/llm-server"
import { testProviderConfig } from "../lib/test-provider"
import { Env } from "@/env"
import { Plugin } from "@/plugin"
@@ -22,70 +21,66 @@ const it = testEffect(
Provider.defaultLayer,
Env.defaultLayer,
Plugin.defaultLayer,
TestLLMServer.layer,
CrossSpawnSpawner.defaultLayer,
),
)
it.live("headerTimeout does not abort delayed SSE body after headers arrive", () =>
provideTmpdirServer(
({ llm }) =>
Effect.gen(function* () {
yield* llm.push(reply().wait(Bun.sleep(250)).text("late").stop())
Effect.gen(function* () {
const server = yield* Effect.acquireRelease(
Effect.promise(() => delayedBodyServer(250)),
(server) => Effect.sync(() => server.server.close()),
)
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
const result = streamText({
model: yield* provider.getLanguage(model),
messages: [{ role: "user", content: "hello" }],
})
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
const result = streamText({
model: yield* provider.getLanguage(model),
messages: [{ role: "user", content: "hello" }],
})
expect(yield* Effect.promise(() => result.text)).toBe("late")
}),
{
config: (url) => {
const config = testProviderConfig(url)
return {
...config,
provider: {
test: {
...config.provider.test,
options: { ...config.provider.test.options, headerTimeout: 50 },
},
},
}
},
},
),
expect(yield* Effect.promise(() => result.text)).toBe("late")
}),
{ config: providerConfig(server.url, { headerTimeout: 50 }) },
)
}),
)
it.live("chunkTimeout raises a response stream error when SSE body stalls", () =>
provideTmpdirServer(
({ llm }) =>
Effect.gen(function* () {
yield* llm.push(reply().wait(Bun.sleep(250)).text("late").stop())
Effect.gen(function* () {
const server = yield* Effect.acquireRelease(
Effect.promise(() => delayedBodyServer(250)),
(server) => Effect.sync(() => server.server.close()),
)
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
const result = streamText({
model: yield* provider.getLanguage(model),
onError() {},
messages: [{ role: "user", content: "hello" }],
})
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
const result = streamText({
model: yield* provider.getLanguage(model),
onError() {},
messages: [{ role: "user", content: "hello" }],
})
const error = yield* Effect.promise(async () => {
try {
for await (const part of result.fullStream) {
if (part.type === "error") return part.error
const error = yield* Effect.promise(async () => {
try {
for await (const part of result.fullStream) {
if (part.type === "error") return part.error
}
} catch (error) {
return error
}
} catch (error) {
return error
}
})
expect(error).toBeInstanceOf(ProviderError.ResponseStreamError)
}),
{ config: (url) => providerConfig(url, { chunkTimeout: 50 }) },
),
})
expect(error).toBeInstanceOf(ProviderError.ResponseStreamError)
}),
{ config: providerConfig(server.url, { chunkTimeout: 50 }) },
)
}),
)
it.live("headerTimeout aborts when response headers do not arrive", () =>
@@ -205,6 +200,20 @@ async function delayedHeaderServer(delay: number): Promise<{ server: Server; url
return { server, url: `http://127.0.0.1:${address.port}` }
}
async function delayedBodyServer(delay: number): Promise<{ server: Server; url: string }> {
const server = createServer((_, res) => {
res.writeHead(200, { "content-type": "text/event-stream" })
res.flushHeaders()
setTimeout(() => {
res.end('data: {"choices":[{"delta":{"content":"late"}}]}\n\ndata: [DONE]\n\n')
}, delay)
})
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
const address = server.address()
if (!address || typeof address === "string") throw new Error("server did not bind to a TCP port")
return { server, url: `http://127.0.0.1:${address.port}` }
}
function withAuthContent<A, E, R>(self: Effect.Effect<A, E, R>, value: Record<string, unknown> = defaultAuthContent()) {
return Effect.acquireUseRelease(
Effect.sync(() => {

View File

@@ -1,6 +1,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
import { describe, expect, test } from "bun:test"
import { tool, type ModelMessage, type JSONValue } from "ai"
@@ -276,6 +277,7 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) {
Layer.provide(Plugin.defaultLayer),
Layer.provide(ModelsDev.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
Layer.provide(LocationServiceMap.layer),
)
// Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real.
const recordedClient = LLMClient.layer.pipe(