diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ed0db805c7..5140909cc2 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,10 +1,11 @@ #!/usr/bin/env bun import { NodeRuntime, NodeServices } from "@effect/platform-node" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import { Commands } from "./commands/commands" import { Runtime } from "./framework/runtime" import { Observability } from "@opencode-ai/util/observability" +import { Client } from "@opencode-ai/util/client" import { Updater } from "./services/updater" import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/util/installation/version" import { LayerNode } from "@opencode-ai/util/effect/layer-node" @@ -73,8 +74,7 @@ Effect.logInfo("cli starting", { Observability.layer({ endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, headers: process.env.OTEL_EXPORTER_OTLP_HEADERS, - client: process.env.OPENCODE_CLIENT ?? "cli", - }), + }).pipe(Layer.provide(Client.layer(process.env.OPENCODE_CLIENT))), ), Effect.provide(NodeServices.layer), Effect.scoped, diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 15541cc307..3bc763882e 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -3,6 +3,7 @@ import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effe import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { ModelsDev } from "@opencode-ai/schema/models-dev" import { Money } from "@opencode-ai/schema/money" +import { Client } from "@opencode-ai/util/client" import { Global } from "@opencode-ai/util/global" import { Flock } from "@opencode-ai/util/flock" import { Hash } from "@opencode-ai/util/hash" @@ -531,17 +532,18 @@ export const Options = Schema.Struct({ url: Schema.optional(Schema.String), file: Schema.optional(Schema.String), fetch: Schema.optional(Schema.Boolean), - client: Schema.optional(Schema.String), }) export type Options = typeof Options.Type export class Service extends Context.Service()("@opencode/ModelsDev") {} -export const layer = (options?: Options) => Layer.effect( +export const layer = (options?: Options) => + Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service const events = yield* EventV2.Service + const client = yield* Client.Name const http = HttpClient.filterStatusOk( (yield* HttpClient.HttpClient).pipe( HttpClient.retryTransient({ @@ -554,7 +556,7 @@ export const layer = (options?: Options) => Layer.effect( const source = options?.url || "https://models.dev" const fetch = options?.fetch ?? true - const userAgent = `opencode/${InstallationChannel}/${InstallationVersion}/${options?.client ?? "cli"}` + const userAgent = `opencode/${InstallationChannel}/${InstallationVersion}/${client}` const filepath = path.join( Global.Path.cache, source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, @@ -581,11 +583,7 @@ export const layer = (options?: Options) => Layer.effect( const loadFromDisk = fs.readJson(options?.file ?? filepath).pipe( Effect.map((input) => input as Record), Effect.catch((error) => { - if ( - options?.file === undefined && - error._tag === "FileSystemError" && - error.method === "readJson" - ) { + if (options?.file === undefined && error._tag === "FileSystemError" && error.method === "readJson") { return fs.remove(filepath, { force: true }).pipe(Effect.ignore, Effect.as(undefined)) } return Effect.succeed(undefined) @@ -659,7 +657,11 @@ export const layer = (options?: Options) => Layer.effect( ) export function configured(options?: Options) { - return makeGlobalNode({ service: Service, layer: layer(options), deps: [FSUtil.node, EventV2.node, httpClient] }) + return makeGlobalNode({ + service: Service, + layer: layer(options), + deps: [FSUtil.node, EventV2.node, Client.node, httpClient], + }) } export const node = configured() diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 8a897e350b..7a18f55f38 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -10,6 +10,7 @@ import { llmClient } from "../effect/app-node-platform" import { SessionEvent } from "./event" import type { SessionMessage } from "./message" import { SessionModelHeaders } from "./model-headers" +import { Client } from "@opencode-ai/util/client" import { SessionRunnerModel } from "./runner/model" import { SessionSchema } from "./schema" import { toSessionError } from "./to-session-error" @@ -60,7 +61,7 @@ type Settings = { } type Dependencies = { - readonly headers?: SessionModelHeaders.Options + readonly client: string readonly events: EventV2.Interface readonly llm: { readonly stream: (request: LLMRequest) => Stream.Stream @@ -259,7 +260,7 @@ const make = (dependencies: Dependencies) => { .stream( LLM.request({ model: plan.model, - http: { headers: SessionModelHeaders.make(plan.session, dependencies.headers) }, + http: { headers: SessionModelHeaders.make(plan.session, dependencies.client) }, messages: [Message.user(plan.prompt)], tools: [], }), @@ -391,23 +392,20 @@ const make = (dependencies: Dependencies) => { }) } -export const layer = (options?: SessionModelHeaders.Options) => Layer.effect( +export const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service const llm = yield* LLMClient.Service const config = yield* Config.Service const models = yield* SessionRunnerModel.Service - return make({ events, llm, models, config: settings(yield* config.entries()), headers: options }) + const client = yield* Client.Name + return make({ events, llm, models, config: settings(yield* config.entries()), client }) }), ) -export function configured(options?: SessionModelHeaders.Options) { - return makeLocationNode({ - service: Service, - layer: layer(options), - deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node], - }) -} - -export const node = configured() +export const node = makeLocationNode({ + service: Service, + layer, + deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node, Client.node], +}) diff --git a/packages/core/src/session/generate-node.ts b/packages/core/src/session/generate-node.ts index 4e3074640e..24be6f835c 100644 --- a/packages/core/src/session/generate-node.ts +++ b/packages/core/src/session/generate-node.ts @@ -4,6 +4,7 @@ import { LLM, LLMClient, Message, SystemPart } from "@opencode-ai/ai" import { Effect, Layer } from "effect" import { Database } from "../database/database" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { Client } from "@opencode-ai/util/client" import { llmClient } from "../effect/app-node-platform" import { PluginHooks } from "../plugin/hooks" import { SessionContext } from "./context" @@ -14,7 +15,7 @@ import { SessionRunnerModel } from "./runner/model" import PROMPT_DEFAULT from "./runner/prompt/base.txt" import { toLLMMessages } from "./runner/to-llm-message" -export const layer = (options?: SessionModelHeaders.Options) => Layer.effect( +export const layer = Layer.effect( SessionGenerate.Service, Effect.gen(function* () { const context = yield* SessionContext.Service @@ -22,6 +23,7 @@ export const layer = (options?: SessionModelHeaders.Options) => Layer.effect( const hooks = yield* PluginHooks.Service const llm = yield* LLMClient.Service const models = yield* SessionRunnerModel.Service + const client = yield* Client.Name return SessionGenerate.Service.of({ generate: Effect.fn("SessionGenerate.generate")(function* (input) { @@ -49,7 +51,7 @@ export const layer = (options?: SessionModelHeaders.Options) => Layer.effect( return (yield* llm.generate( LLM.request({ model: model.model, - http: { headers: SessionModelHeaders.make(selection.session, options) }, + http: { headers: SessionModelHeaders.make(selection.session, client) }, providerOptions: { openai: { promptCacheKey } }, system: contextEvent.system, messages: contextEvent.messages, @@ -62,12 +64,8 @@ export const layer = (options?: SessionModelHeaders.Options) => Layer.effect( }), ) -export function configured(options?: SessionModelHeaders.Options) { - return makeLocationNode({ - service: SessionGenerate.Service, - layer: layer(options), - deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, llmClient], - }) -} - -export const node = configured() +export const node = makeLocationNode({ + service: SessionGenerate.Service, + layer, + deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, Client.node, llmClient], +}) diff --git a/packages/core/src/session/model-headers.ts b/packages/core/src/session/model-headers.ts index 5e9f75821c..cbe9d78f2b 100644 --- a/packages/core/src/session/model-headers.ts +++ b/packages/core/src/session/model-headers.ts @@ -2,22 +2,13 @@ export * as SessionModelHeaders from "./model-headers" import { InstallationVersion } from "@opencode-ai/util/installation/version" import { SessionSchema } from "./schema" -import { Schema } from "effect" -export const Options = Schema.Struct({ - client: Schema.optional(Schema.String), -}) -export type Options = typeof Options.Type - -export const make = ( - session: Pick, - options?: Options, -) => ({ +export const make = (session: Pick, client: string) => ({ "x-session-affinity": session.id, "X-Session-Id": session.id, ...(session.parentID ? { "x-parent-session-id": session.parentID } : {}), "User-Agent": `opencode/${InstallationVersion}`, "x-opencode-project": session.projectID, "x-opencode-session": session.id, - "x-opencode-client": options?.client ?? "cli", + "x-opencode-client": client, }) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 5b460433b8..73fef668e0 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -4,6 +4,7 @@ import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai" import { SessionError } from "@opencode-ai/schema/session-error" import { Context, Effect, Layer } from "effect" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { Client } from "@opencode-ai/util/client" import { PluginHooks } from "../plugin/hooks" import { ToolRegistry } from "../tool/registry" import { SessionContext } from "./context" @@ -39,11 +40,12 @@ export interface Interface { /** Location-scoped outbound model-request preparation. */ export class Service extends Context.Service()("@opencode/v2/SessionModelRequest") {} -export const layer = (options?: SessionModelHeaders.Options) => Layer.effect( +export const layer = Layer.effect( Service, Effect.gen(function* () { const hooks = yield* PluginHooks.Service const registry = yield* ToolRegistry.Service + const client = yield* Client.Name const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) { const session = input.context.session @@ -81,7 +83,7 @@ export const layer = (options?: SessionModelHeaders.Options) => Layer.effect( const request = LLM.request({ model, http: { - headers: SessionModelHeaders.make(session, options), + headers: SessionModelHeaders.make(session, client), }, providerOptions: { openai: { promptCacheKey } }, system: contextEvent.system, @@ -112,8 +114,8 @@ export const layer = (options?: SessionModelHeaders.Options) => Layer.effect( }), ) -export function configured(options?: SessionModelHeaders.Options) { - return makeLocationNode({ service: Service, layer: layer(options), deps: [PluginHooks.node, ToolRegistry.node] }) -} - -export const node = configured() +export const node = makeLocationNode({ + service: Service, + layer, + deps: [PluginHooks.node, ToolRegistry.node, Client.node], +}) diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index fd58a7c87d..a506cb1cc1 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -6,6 +6,7 @@ import { AgentV2 } from "../agent" import { Database } from "../database/database" import { EventV2 } from "../event" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { Client } from "@opencode-ai/util/client" import { llmClient } from "../effect/app-node-platform" import { SessionEvent } from "./event" import { SessionHistory } from "./history" @@ -17,7 +18,7 @@ import { SessionUsage } from "./usage" const MAX_LENGTH = 100 type Dependencies = { - readonly headers?: SessionModelHeaders.Options + readonly client: string readonly events: EventV2.Interface readonly llm: { readonly stream: (request: LLMRequest) => Stream.Stream @@ -67,7 +68,7 @@ const make = (dependencies: Dependencies) => { .stream( LLM.request({ model: resolved.model, - http: { headers: SessionModelHeaders.make(session, dependencies.headers) }, + http: { headers: SessionModelHeaders.make(session, dependencies.client) }, system: agent.system, messages: [Message.user(firstUser.text)], tools: [], @@ -103,7 +104,7 @@ const make = (dependencies: Dependencies) => { return { generateForFirstPrompt } } -export const layer = (options?: SessionModelHeaders.Options) => Layer.effect( +export const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service @@ -111,19 +112,16 @@ export const layer = (options?: SessionModelHeaders.Options) => Layer.effect( const agents = yield* AgentV2.Service const models = yield* SessionRunnerModel.Service const database = yield* Database.Service - const title = make({ events, llm, agents, models, headers: options }) + const client = yield* Client.Name + const title = make({ events, llm, agents, models, client }) return Service.of({ generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session), }) }), ) -export function configured(options?: SessionModelHeaders.Options) { - return makeLocationNode({ - service: Service, - layer: layer(options), - deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node], - }) -} - -export const node = configured() +export const node = makeLocationNode({ + service: Service, + layer, + deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node, Client.node], +}) diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index 49b915abbe..479e0ec836 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -56,7 +56,7 @@ const locations = Layer.effect( () => // The test only needs the compaction location service used by SessionV2.compact. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion - SessionCompaction.layer().pipe( + SessionCompaction.layer.pipe( Layer.provide(client), Layer.provide(config), Layer.provide(models), diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index bd6b9cc7c2..60adaec4bc 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -9,9 +9,11 @@ export const create = Effect.fn("OpenCode.create")(function* (options: ServerOpt const runtime = yield* Effect.acquireRelease( Effect.sync(() => ManagedRuntime.make( - createEmbeddedRoutes({ ...options, database: { path: ":memory:", ...options.database } }).pipe( - Layer.provide(HttpServer.layerServices), - ), + createEmbeddedRoutes({ + ...options, + client: options.client ?? "sdk", + database: { path: ":memory:", ...options.database }, + }).pipe(Layer.provide(HttpServer.layerServices)), ), ), (runtime) => runtime.disposeEffect, diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 4bf3a56a9b..f8985cc007 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -6,6 +6,7 @@ import { EventV2 } from "@opencode-ai/core/event" import { EventLogger } from "@opencode-ai/core/event-logger" import { FileSystemSearch } from "@opencode-ai/core/filesystem/search" import { Observability } from "@opencode-ai/util/observability" +import { Client } from "@opencode-ai/util/client" import { Credential } from "@opencode-ai/core/credential" import { Config } from "@opencode-ai/core/config" import { CommandV2 } from "@opencode-ai/core/command" @@ -87,7 +88,8 @@ function makeRoutes( const pluginRuntimeCell = PluginRuntime.makeCell() const replacements: LayerNode.Replacements = [ [Database.node, Database.configured(options.database)], - [ModelsDev.node, ModelsDev.configured({ ...options.models, client: options.client })], + [Client.node, Client.configured(options.client)], + [ModelsDev.node, ModelsDev.configured(options.models)], [Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })], [FileSystemSearch.node, FileSystemSearch.configured({ fff: options.fs?.fff })], [Global.node, Global.layerWith(options.config?.directory ? { config: options.config.directory } : {})], @@ -103,10 +105,6 @@ function makeRoutes( [CommandV2.node, CommandV2.configured({ gitbash: options.windows?.gitbash })], [Pty.node, Pty.configured({ gitbash: options.windows?.gitbash })], [Shell.node, Shell.configured({ gitbash: options.windows?.gitbash })], - [SessionCompaction.node, SessionCompaction.configured({ client: options.client })], - [SessionGenerateNode.node, SessionGenerateNode.configured({ client: options.client })], - [SessionModelRequest.node, SessionModelRequest.configured({ client: options.client })], - [SessionTitle.node, SessionTitle.configured({ client: options.client })], [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], ] @@ -135,7 +133,7 @@ function makeRoutes( Layer.provide(authorizationLayer), Layer.provide(schemaErrorLayer), Layer.provide(auth), - Layer.provide(Observability.layer({ ...options.observability, client: options.client })), + Layer.provide(Observability.layer(options.observability).pipe(Layer.provide(Client.layer(options.client)))), HttpRouter.provideRequest(requestServices), Layer.provideMerge(services), Layer.provideMerge(HttpRouter.layer), diff --git a/packages/util/src/client.ts b/packages/util/src/client.ts new file mode 100644 index 0000000000..8375e23616 --- /dev/null +++ b/packages/util/src/client.ts @@ -0,0 +1,14 @@ +export * as Client from "./client.js" + +import { Context, Layer } from "effect" +import { makeGlobalNode } from "./effect/app-node.js" + +export const Name = Context.Reference("@opencode/Client/Name", { + defaultValue: () => "cli", +}) + +export const layer = (name = "cli") => Layer.succeed(Name, name) + +export const configured = (name?: string) => makeGlobalNode({ service: Name, layer: layer(name), deps: [] }) + +export const node = configured() diff --git a/packages/util/src/observability.ts b/packages/util/src/observability.ts index ecc2363e40..f356aa03fb 100644 --- a/packages/util/src/observability.ts +++ b/packages/util/src/observability.ts @@ -7,11 +7,11 @@ import { FetchHttpClient } from "effect/unstable/http" import { OtlpSerialization } from "effect/unstable/observability" import { Logging } from "./observability/logging.js" import { Otlp } from "./observability/otlp.js" +import { Client } from "./client.js" export const Options = Schema.Struct({ endpoint: Schema.optional(Schema.String), headers: Schema.optional(Schema.String), - client: Schema.optional(Schema.String), }) export type Options = typeof Options.Type @@ -19,7 +19,6 @@ export function layer( options: Options = { endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, headers: process.env.OTEL_EXPORTER_OTLP_HEADERS, - client: process.env.OPENCODE_CLIENT ?? "cli", }, ) { const local = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe( @@ -29,14 +28,17 @@ export function layer( ) return Layer.unwrap( Effect.gen(function* () { - const logs = Logger.layer([...Logging.loggers(), ...Otlp.loggers(options)], { mergeWithExisting: false }).pipe( + const client = yield* Client.Name + const logs = Logger.layer([...Logging.loggers(), ...Otlp.loggers(options, client)], { + mergeWithExisting: false, + }).pipe( Layer.provide(NodeFileSystem.layer), Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer), Layer.orDie, Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())), ) - return Layer.merge(logs, yield* Effect.promise(() => Otlp.tracingLayer(options))) + return Layer.merge(logs, yield* Effect.promise(() => Otlp.tracingLayer(options, client))) }), ).pipe(Layer.catchCause(() => local)) } diff --git a/packages/util/src/observability/otlp.ts b/packages/util/src/observability/otlp.ts index eb6771e54c..9ac4b7ce6d 100644 --- a/packages/util/src/observability/otlp.ts +++ b/packages/util/src/observability/otlp.ts @@ -6,19 +6,18 @@ import { runID } from "./shared.js" export interface Options { readonly endpoint?: string readonly headers?: string - readonly client?: string } function parseHeaders(value?: string) { return value ? value.split(",").reduce( - (acc, entry) => { - const [key, ...value] = entry.split("=") - acc[key] = value.join("=") - return acc - }, - {} as Record, - ) + (acc, entry) => { + const [key, ...value] = entry.split("=") + acc[key] = value.join("=") + return acc + }, + {} as Record, + ) : undefined } @@ -38,7 +37,11 @@ function resourceAttributes() { } } -export function resource(client = "cli"): { serviceName: string; serviceVersion: string; attributes: Record } { +export function resource(client = "cli"): { + serviceName: string + serviceVersion: string + attributes: Record +} { return { serviceName: "opencode", serviceVersion: InstallationVersion, @@ -52,18 +55,18 @@ export function resource(client = "cli"): { serviceName: string; serviceVersion: } } -export function loggers(options?: Options) { +export function loggers(options: Options | undefined, client: string) { if (!options?.endpoint) return [] return [ OtlpLogger.make({ url: `${options.endpoint}/v1/logs`, - resource: resource(options.client), + resource: resource(client), headers: parseHeaders(options.headers), }), ] } -export async function tracingLayer(options?: Options) { +export async function tracingLayer(options: Options | undefined, client: string) { if (!options?.endpoint) return Layer.empty const NodeSdk = await import("@effect/opentelemetry/NodeSdk") const OTLP = await import("@opentelemetry/exporter-trace-otlp-http") @@ -77,7 +80,7 @@ export async function tracingLayer(options?: Options) { context.setGlobalContextManager(manager) return NodeSdk.layer(() => ({ - resource: resource(options.client), + resource: resource(client), spanProcessor: new SdkBase.BatchSpanProcessor( new OTLP.OTLPTraceExporter({ url: `${options.endpoint}/v1/traces`,