feat(core): add location-scoped config loading (#29625)
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user