From bbd5faf5cdc702a143f4b1438405131312a63533 Mon Sep 17 00:00:00 2001 From: Caleb Norton Date: Tue, 14 Apr 2026 16:49:44 -0500 Subject: [PATCH 1/6] chore(nix): remove external ripgrep (#22482) --- nix/opencode.nix | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/nix/opencode.nix b/nix/opencode.nix index b629d0b554..4deac157e2 100644 --- a/nix/opencode.nix +++ b/nix/opencode.nix @@ -7,7 +7,6 @@ sysctl, makeBinaryWrapper, models-dev, - ripgrep, installShellFiles, versionCheckHook, writableTmpDirAsHomeHook, @@ -52,25 +51,25 @@ stdenvNoCC.mkDerivation (finalAttrs: { runHook postBuild ''; - installPhase = '' - runHook preInstall + installPhase = + '' + runHook preInstall - install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode - install -Dm644 schema.json $out/share/opencode/schema.json - - wrapProgram $out/bin/opencode \ - --prefix PATH : ${ - lib.makeBinPath ( - [ - ripgrep + install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode + install -Dm644 schema.json $out/share/opencode/schema.json + '' + # bun runs sysctl to detect if dunning on rosetta2 + + lib.optionalString stdenvNoCC.hostPlatform.isDarwin '' + wrapProgram $out/bin/opencode \ + --prefix PATH : ${ + lib.makeBinPath [ + sysctl ] - # bun runs sysctl to detect if dunning on rosetta2 - ++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl - ) - } - - runHook postInstall - ''; + } + '' + + '' + runHook postInstall + ''; postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) '' # trick yargs into also generating zsh completions From f9d99f044df4d506aac897a1c27d1a0b1f894ae9 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 15 Apr 2026 08:02:27 +1000 Subject: [PATCH 2/6] fix(session): keep GitHub Copilot compaction requests valid (#22371) --- packages/opencode/src/session/llm.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 3ab35958a4..732ad7a9f3 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -205,7 +205,11 @@ export namespace LLM { // calls but no tools param is present. When there are no active tools (e.g. // during compaction), inject a stub tool to satisfy the validation requirement. // The stub description explicitly tells the model not to call it. - if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) { + if ( + (isLiteLLMProxy || input.model.providerID.includes("github-copilot")) && + Object.keys(tools).length === 0 && + hasToolCalls(input.messages) + ) { tools["_noop"] = tool({ description: "Do not call this tool. It exists only for API compatibility and must never be invoked.", inputSchema: jsonSchema({ From f6409759e569cb3cf0479f9ba3453ff3b40ed1c2 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 15 Apr 2026 03:59:12 +0530 Subject: [PATCH 3/6] fix: restore instance context in prompt runs (#22498) --- packages/opencode/src/effect/app-runtime.ts | 25 ++++++++++- packages/opencode/src/session/prompt.ts | 31 ++++++++------ .../test/effect/app-runtime-logger.test.ts | 19 +++++++++ .../test/session/prompt-effect.test.ts | 42 +++++++++++++++++++ 4 files changed, 103 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 674ca1a2ac..0d32bce088 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -1,5 +1,5 @@ import { Layer, ManagedRuntime } from "effect" -import { memoMap } from "./run-service" +import { attach, memoMap } from "./run-service" import { Observability } from "./oltp" import { AppFileSystem } from "@/filesystem" @@ -97,4 +97,25 @@ export const AppLayer = Layer.mergeAll( SessionShare.defaultLayer, ) -export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap }) +const rt = ManagedRuntime.make(AppLayer, { memoMap }) +type Runtime = Pick +const wrap = (effect: Parameters[0]) => attach(effect as never) as never + +export const AppRuntime: Runtime = { + runSync(effect) { + return rt.runSync(wrap(effect)) + }, + runPromise(effect, options) { + return rt.runPromise(wrap(effect), options) + }, + runPromiseExit(effect, options) { + return rt.runPromiseExit(wrap(effect), options) + }, + runFork(effect) { + return rt.runFork(wrap(effect)) + }, + runCallback(effect) { + return rt.runCallback(wrap(effect)) + }, + dispose: () => rt.dispose(), +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a763b27b97..3efcc03657 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -104,12 +104,21 @@ export namespace SessionPrompt { const summary = yield* SessionSummary.Service const sys = yield* SystemPrompt.Service const llm = yield* LLM.Service - const ctx = yield* Effect.context() - - const run = { - promise: (effect: Effect.Effect) => Effect.runPromiseWith(ctx)(effect), - fork: (effect: Effect.Effect) => Effect.runForkWith(ctx)(effect), - } + const runner = Effect.fn("SessionPrompt.runner")(function* () { + const ctx = yield* Effect.context() + return { + promise: (effect: Effect.Effect) => Effect.runPromiseWith(ctx)(effect), + fork: (effect: Effect.Effect) => Effect.runForkWith(ctx)(effect), + } + }) + const ops = Effect.fn("SessionPrompt.ops")(function* () { + const run = yield* runner() + return { + cancel: (sessionID: SessionID) => run.fork(cancel(sessionID)), + resolvePromptParts: (template: string) => resolvePromptParts(template), + prompt: (input: PromptInput) => prompt(input), + } satisfies TaskPromptOps + }) const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { yield* elog.info("cancel", { sessionID }) @@ -359,6 +368,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) { using _ = log.time("resolveTools") const tools: Record = {} + const run = yield* runner() + const promptOps = yield* ops() const context = (args: any, options: ToolExecutionOptions): Tool.Context => ({ sessionID: input.session.id, @@ -528,6 +539,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) { const { task, model, lastUser, sessionID, session, msgs } = input const ctx = yield* InstanceState.context + const promptOps = yield* ops() const { task: taskTool } = yield* registry.named() const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({ @@ -712,6 +724,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput) { const ctx = yield* InstanceState.context + const run = yield* runner() const session = yield* sessions.get(input.sessionID) if (session.revert) { yield* revert.cleanup(session) @@ -1659,12 +1672,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the return result }) - const promptOps: TaskPromptOps = { - cancel: (sessionID) => run.fork(cancel(sessionID)), - resolvePromptParts: (template) => resolvePromptParts(template), - prompt: (input) => prompt(input), - } - return Service.of({ cancel, prompt, diff --git a/packages/opencode/test/effect/app-runtime-logger.test.ts b/packages/opencode/test/effect/app-runtime-logger.test.ts index c09775be3a..8a7aab6cf8 100644 --- a/packages/opencode/test/effect/app-runtime-logger.test.ts +++ b/packages/opencode/test/effect/app-runtime-logger.test.ts @@ -1,8 +1,11 @@ import { expect, test } from "bun:test" import { Context, Effect, Layer, Logger } from "effect" import { AppRuntime } from "../../src/effect/app-runtime" +import { InstanceRef } from "../../src/effect/instance-ref" import { EffectLogger } from "../../src/effect/logger" import { makeRuntime } from "../../src/effect/run-service" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" function check(loggers: ReadonlySet>) { return { @@ -40,3 +43,19 @@ test("AppRuntime also installs EffectLogger through Observability.layer", async expect(current.effectLogger).toBe(true) expect(current.defaultLogger).toBe(false) }) + +test("AppRuntime attaches InstanceRef from ALS", async () => { + await using tmp = await tmpdir({ git: true }) + + const dir = await Instance.provide({ + directory: tmp.path, + fn: () => + AppRuntime.runPromise( + Effect.gen(function* () { + return (yield* InstanceRef)?.directory + }), + ), + }) + + expect(dir).toBe(tmp.path) +}) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 9523915bd9..244f778ca8 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -483,6 +483,48 @@ it.live("loop continues when finish is tool-calls", () => ), ) +it.live("glob tool keeps instance context during prompt runs", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "Glob context", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + const file = path.join(dir, "probe.txt") + yield* Effect.promise(() => Bun.write(file, "probe")) + + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "find text files" }], + }) + yield* llm.tool("glob", { pattern: "**/*.txt" }) + yield* llm.text("done") + + const result = yield* prompt.loop({ sessionID: session.id }) + expect(result.info.role).toBe("assistant") + + const msgs = yield* MessageV2.filterCompactedEffect(session.id) + const tool = msgs + .flatMap((msg) => msg.parts) + .find( + (part): part is CompletedToolPart => + part.type === "tool" && part.tool === "glob" && part.state.status === "completed", + ) + if (!tool) return + + expect(tool.state.output).toContain(file) + expect(tool.state.output).not.toContain("No context found for instance") + expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true) + }), + { git: true, config: providerCfg }, + ), +) + it.live("loop continues when finish is stop but assistant has tool parts", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { From 6706358a6e93daffcde534d4c23fb934a6be2fad Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 15 Apr 2026 04:01:45 +0530 Subject: [PATCH 4/6] feat(core): bootstrap packages/server and document extraction plan (#22492) --- bun.lock | 9 + .../opencode/specs/effect/server-package.md | 666 ++++++++++++++++++ packages/server/package.json | 24 + packages/server/src/api/index.ts | 1 + packages/server/src/definition/api.ts | 6 + packages/server/src/definition/index.ts | 1 + packages/server/src/index.ts | 3 + packages/server/src/openapi.ts | 14 + packages/server/src/types.ts | 14 + packages/server/tsconfig.json | 15 + 10 files changed, 753 insertions(+) create mode 100644 packages/opencode/specs/effect/server-package.md create mode 100644 packages/server/package.json create mode 100644 packages/server/src/api/index.ts create mode 100644 packages/server/src/definition/api.ts create mode 100644 packages/server/src/definition/index.ts create mode 100644 packages/server/src/index.ts create mode 100644 packages/server/src/openapi.ts create mode 100644 packages/server/src/types.ts create mode 100644 packages/server/tsconfig.json diff --git a/bun.lock b/bun.lock index 859b79ee1f..a8814e9a85 100644 --- a/bun.lock +++ b/bun.lock @@ -498,6 +498,13 @@ "typescript": "catalog:", }, }, + "packages/server": { + "name": "@opencode-ai/server", + "version": "1.4.3", + "devDependencies": { + "typescript": "catalog:", + }, + }, "packages/slack": { "name": "@opencode-ai/slack", "version": "1.4.3", @@ -1533,6 +1540,8 @@ "@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"], + "@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"], + "@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"], "@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"], diff --git a/packages/opencode/specs/effect/server-package.md b/packages/opencode/specs/effect/server-package.md new file mode 100644 index 0000000000..10be7b9aed --- /dev/null +++ b/packages/opencode/specs/effect/server-package.md @@ -0,0 +1,666 @@ +# Server package extraction + +Practical reference for extracting a future `packages/server` from the current `packages/opencode` monolith while `packages/core` is still being migrated to Effect. + +This document is intentionally execution-oriented. + +It should give an agent enough context to land one incremental PR at a time without needing to rediscover the package strategy, route migration rules, or current constraints. + +## Goal + +Create `packages/server` as the home for: + +- HTTP contract definitions +- HTTP handler implementations +- OpenAPI generation +- eventual embeddable server APIs for Node apps + +Do this without blocking on the full `packages/core` extraction. + +## Future state + +Target package layout: + +- `packages/core` - all opencode services, Effect-first source of truth +- `packages/server` - opencode server, with separate contract and implementation, still producing `openapi.json` +- `packages/cli` - TUI + CLI entrypoints +- `packages/sdk` - generated from the server OpenAPI spec, may add higher-level wrappers +- `packages/plugin` - generated or semi-hand-rolled non-Effect package built from core plugin definitions + +Desired user stories: + +- import from `core` and build a custom agent or app-specific runtime +- import from `server` and embed the full opencode server into an existing Node app +- spawn the CLI and talk to the server through that boundary + +## Current state + +Everything still lives in `packages/opencode`. + +Important current facts: + +- there is no `packages/core` or `packages/cli` workspace yet +- `packages/server` now exists as a minimal scaffold package, but it does not own any real route contracts, handlers, or runtime composition yet +- the main host server is still Hono-based in `src/server/server.ts` +- current OpenAPI generation is Hono-based through `Server.openapi()` and `cli/cmd/generate.ts` +- the Effect runtime and app layer are centralized in `src/effect/app-runtime.ts` and `src/effect/run-service.ts` +- there is already one experimental Effect `HttpApi` slice at `src/server/instance/httpapi/question.ts` +- that experimental slice is mounted under `/experimental/httpapi/question` +- that experimental slice already has an end-to-end test at `test/server/question-httpapi.test.ts` + +This means the package split should start from an extraction path, not from greenfield package ownership. + +## Structural reference + +Use `anomalyco/opentunnel` as the structural reference for `packages/server`. + +The important pattern there is: + +- `packages/core` owns services and domain schemas +- `packages/server/src/definition/*` owns pure `HttpApi` contracts +- `packages/server/src/api/*` owns `HttpApiBuilder.group(...)` implementations and server-side middleware wiring +- `packages/server/src/index.ts` becomes the composition root only after the server package really owns runtime hosting + +Relevant `opentunnel` files: + +- `packages/server/src/definition/index.ts` +- `packages/server/src/definition/tunnel.ts` +- `packages/server/src/api/index.ts` +- `packages/server/src/api/tunnel.ts` +- `packages/server/src/api/client.ts` +- `packages/server/src/index.ts` + +The intended direction here is the same, but the current `opencode` package split is earlier in the migration. + +That means: + +- we should follow the same `definition` and `api` naming +- we should keep contract and implementation as separate modules from the start +- we should postpone the runtime composition root until `packages/core` exists enough to support it cleanly + +## Key decision + +Start `packages/server` as a contract and implementation package only. + +Do not make it the runtime host yet. + +Why: + +- `packages/core` does not exist yet +- the current server host still lives in `packages/opencode` +- moving host ownership immediately would force a large package and runtime shuffle while Effect service extraction is still in flight +- if `packages/server` imports services from `packages/opencode` while `packages/opencode` imports `packages/server` to host routes, we create a package cycle immediately + +Short version: + +1. create `packages/server` +2. move pure `HttpApi` contracts there +3. move handler factories there +4. keep `packages/opencode` as the temporary Hono host +5. merge `packages/server` OpenAPI with the legacy Hono OpenAPI during the transition +6. move server hosting later, after `packages/core` exists enough + +## Dependency rule + +Phase 1 rule: + +- `packages/server` must not import from `packages/opencode` + +Allowed in phase 1: + +- `packages/opencode` imports `packages/server` +- `packages/server` accepts host-provided services, layers, or callbacks as inputs +- `packages/server` may temporarily own transport-local placeholder schemas when a canonical shared schema does not exist yet + +Future rule after `packages/core` exists: + +- `packages/server` imports from `packages/core` +- `packages/cli` imports from `packages/server` and `packages/core` +- `packages/opencode` shrinks or disappears as package responsibilities are fully split + +## HttpApi model + +Use Effect v4 `HttpApi` as the source of truth for migrated HTTP routes. + +Important properties from the current `effect` / `effect-smol` model: + +- `HttpApi`, `HttpApiGroup`, and `HttpApiEndpoint` are pure contract definitions +- handlers are implemented separately with `HttpApiBuilder.group(...)` +- OpenAPI can be generated from the contract alone +- auth and middleware can later be modeled with `HttpApiMiddleware.Service` +- SSE and websocket routes are not good first-wave `HttpApi` targets + +This package split should preserve that separation explicitly. + +Default shape for migrated routes: + +- contract lives in `packages/server/src/definition/*` +- implementation lives in `packages/server/src/api/*` +- host mounting stays outside for now + +## OpenAPI rule + +During the transition there is still one spec artifact. + +Default rule: + +- `packages/server` generates OpenAPI from `HttpApi` contract +- `packages/opencode` keeps generating legacy OpenAPI from Hono routes +- the temporary exported server spec is a merged document +- `packages/sdk` continues consuming one `openapi.json` + +Merge safety rules: + +- fail on duplicate `path + method` +- fail on duplicate `operationId` +- prefer explicit summary, description, and operation ids on all new `HttpApi` endpoints + +Practical implication: + +- do not make the SDK consume two specs +- do not switch SDK generation to `packages/server` only until enough of the route surface has moved + +## Package shape + +Minimum viable `packages/server`: + +- `src/index.ts` +- `src/definition/index.ts` +- `src/definition/api.ts` +- `src/definition/question.ts` +- `src/api/index.ts` +- `src/api/question.ts` +- `src/openapi.ts` +- `src/bridge/hono.ts` +- `src/types.ts` + +Later additions, once there is enough real contract surface: + +- `src/api/client.ts` +- runtime composition in `src/index.ts` + +Suggested initial exports: + +- `api` +- `openapi` +- `questionApi` +- `makeQuestionHandler` + +Phase 1 responsibilities: + +- own pure API contracts +- own handler factories for migrated slices +- own contract-generated OpenAPI +- expose host adapters needed by `packages/opencode` + +Phase 1 non-goals: + +- do not own `listen()` +- do not own adapter selection +- do not own global server middleware +- do not own websocket or SSE transport +- do not own process bootstrapping for CLI entrypoints + +## Current source inventory + +These files matter for the first phase. + +Current host and route composition: + +- `src/server/server.ts` +- `src/server/control/index.ts` +- `src/server/instance/index.ts` +- `src/server/middleware.ts` +- `src/server/adapter.bun.ts` +- `src/server/adapter.node.ts` + +Current experimental `HttpApi` slice: + +- `src/server/instance/httpapi/question.ts` +- `src/server/instance/httpapi/index.ts` +- `src/server/instance/experimental.ts` +- `test/server/question-httpapi.test.ts` + +Current OpenAPI flow: + +- `src/server/server.ts` via `Server.openapi()` +- `src/cli/cmd/generate.ts` +- `packages/sdk/js/script/build.ts` + +Current runtime and service layer: + +- `src/effect/app-runtime.ts` +- `src/effect/run-service.ts` + +## Ownership rules + +Move first into `packages/server`: + +- the experimental `question` `HttpApi` slice +- future `provider` and `config` JSON read slices +- any new `HttpApi` route groups +- transport-local OpenAPI generation for migrated routes + +Keep in `packages/opencode` for now: + +- `src/server/server.ts` +- `src/server/control/index.ts` +- `src/server/instance/*.ts` +- `src/server/middleware.ts` +- `src/server/adapter.*.ts` +- `src/effect/app-runtime.ts` +- `src/effect/run-service.ts` +- all Effect services until they move to `packages/core` + +## Placeholder schema rule + +`packages/core` is allowed to lag behind. + +Until shared canonical schemas move to `packages/core`: + +- prefer importing existing Effect Schema DTOs from current locations when practical +- if a route only needs a transport-local type and moving the canonical schema would create unrelated churn, allow a temporary server-local placeholder schema +- if a placeholder is introduced, leave a short note so it does not become permanent + +The default rule from `schema.md` still applies: + +- Effect Schema owns the type +- `.zod` is compatibility only +- avoid parallel hand-written Zod and Effect definitions for the same migrated route shape + +## Host boundary rule + +Until host ownership moves: + +- auth stays at the outer Hono app level +- compression stays at the outer Hono app level +- CORS stays at the outer Hono app level +- instance and workspace lookup stay at the current middleware layer +- `packages/server` handlers should assume the host already provided the right request context +- do not redesign host middleware just to land the package split + +This matches the current guidance in `http-api.md`: + +- keep auth outside the first parallel `HttpApi` slices +- keep instance lookup outside the first parallel `HttpApi` slices +- keep the first migrations transport-focused and semantics-preserving + +## Route selection rules + +Good early migration targets: + +- `question` +- `provider` auth read endpoint +- `config` providers read endpoint +- small read-only instance routes + +Bad early migration targets: + +- `session` +- `event` +- `pty` +- most `global` streaming or process-heavy routes +- anything requiring websocket upgrade handling +- anything that mixes many mutations and streaming in one file + +## First vertical slice + +The first slice for the package split is the existing experimental `question` group. + +Why `question` first: + +- it already exists as an experimental `HttpApi` slice +- it already follows the desired contract and implementation split in one file +- it is already mounted through the current Hono host +- it already has an end-to-end test +- it is JSON-only +- it has low blast radius + +Use the first slice to prove: + +- package boundary +- contract and implementation split +- host mounting from `packages/opencode` +- merged OpenAPI output +- test ergonomics for future slices + +Do not broaden scope in the first slice. + +## Incremental migration order + +Use small PRs. + +Each PR should be easy to review, easy to revert, and should not mix extraction work with unrelated service refactors. + +### PR 1. Create `packages/server` + +Scope: + +- add the new workspace package +- add package manifest and tsconfig +- add empty `src/index.ts`, `src/definition/api.ts`, `src/definition/index.ts`, `src/api/index.ts`, `src/openapi.ts`, and supporting scaffolding + +Rules: + +- no production behavior changes +- no host server changes yet +- no imports from `packages/opencode` inside `packages/server` +- prefer `opentunnel`-style naming from the start: `definition` for contracts, `api` for implementations + +Done means: + +- `packages/server` typechecks +- the workspace can import it +- the package boundary is in place for follow-up PRs + +### PR 2. Move the experimental question contract + +Scope: + +- extract the pure `HttpApi` contract from `src/server/instance/httpapi/question.ts` +- place it in `packages/server/src/definition/question.ts` +- aggregate it in `packages/server/src/definition/api.ts` +- generate OpenAPI in `packages/server/src/openapi.ts` + +Rules: + +- contract only in this PR +- no handler movement yet if that keeps the diff simpler +- keep operation ids and docs metadata stable + +Done means: + +- question contract lives in `packages/server` +- OpenAPI can be generated from contract alone +- no runtime behavior changes yet + +### PR 3. Move the experimental question handler factory + +Scope: + +- extract the question `HttpApiBuilder.group(...)` implementation into `packages/server/src/api/question.ts` +- expose it as a factory that accepts host-provided dependencies or wiring +- add a small Hono bridge in `packages/server/src/bridge/hono.ts` if needed + +Rules: + +- `packages/server` must still not import from `packages/opencode` +- handler code should stay thin and service-delegating +- do not redesign the question service itself in this PR + +Done means: + +- `packages/server` can produce the experimental question handler +- the package still stays cycle-free + +### PR 4. Mount `packages/server` question from `packages/opencode` + +Scope: + +- replace local experimental question route wiring in `packages/opencode` +- keep the same mount path: +- `/experimental/httpapi/question` +- `/experimental/httpapi/question/doc` + +Rules: + +- no behavior change +- preserve existing docs path +- preserve current request and response shapes + +Done means: + +- existing question `HttpApi` test still passes +- runtime behavior is unchanged +- the current host server is now consuming `packages/server` + +### PR 5. Merge legacy and contract OpenAPI + +Scope: + +- keep `Server.openapi()` as the temporary spec entrypoint +- generate legacy Hono spec +- generate `packages/server` contract spec +- merge them into one document +- keep `cli/cmd/generate.ts` and `packages/sdk/js/script/build.ts` consuming one spec + +Rules: + +- fail loudly on duplicate `path + method` +- fail loudly on duplicate `operationId` +- do not silently overwrite one source with the other + +Done means: + +- one merged spec is produced +- migrated question paths can come from `packages/server` +- existing SDK generation path still works + +### PR 6. Add merged OpenAPI coverage + +Scope: + +- add one test for merged OpenAPI +- assert both a legacy Hono route and a migrated `HttpApi` route exist + +Rules: + +- test the merged document, not just the `packages/server` contract spec in isolation +- pick one stable legacy route and one stable migrated route + +Done means: + +- the merged-spec path is covered +- future route migrations have a guardrail + +### PR 7. Migrate `GET /provider/auth` + +Scope: + +- add `GET /provider/auth` as the next `HttpApi` slice in `packages/server` +- mount it in parallel from `packages/opencode` + +Why this route: + +- JSON-only +- simple service delegation +- small response shape +- already listed as the best next `provider` candidate in `http-api.md` + +Done means: + +- route works through the current host +- route appears in merged OpenAPI +- no semantic change to provider auth behavior + +### PR 8. Migrate `GET /config/providers` + +Scope: + +- add `GET /config/providers` as a `HttpApi` slice in `packages/server` +- mount it in parallel from `packages/opencode` + +Why this route: + +- JSON-only +- read-only +- low transport complexity +- already listed as the best next `config` candidate in `http-api.md` + +Done means: + +- route works unchanged +- route appears in merged OpenAPI + +### PR 9+. Migrate small read-only instance routes + +Candidate order: + +1. `GET /path` +2. `GET /vcs` +3. `GET /vcs/diff` +4. `GET /command` +5. `GET /agent` +6. `GET /skill` + +Rules: + +- one or two endpoints per PR +- prefer read-only routes first +- keep outer middleware unchanged +- keep business logic in the existing service layer + +Done means for each PR: + +- contract lives in `packages/server` +- handler lives in `packages/server` +- route is mounted from the current host +- route appears in merged OpenAPI +- behavior remains unchanged + +### Later PR. Move host ownership into `packages/server` + +Only start this after there is enough `packages/core` surface to depend on directly. + +Scope: + +- move server composition into `packages/server` +- add embeddable APIs such as `createServer(...)`, `listen(...)`, or `createApp(...)` +- move adapter selection and server startup out of `packages/opencode` + +Rules: + +- do not start this while `packages/server` still depends on `packages/opencode` +- do not mix this with route migration PRs + +Done means: + +- `packages/server` can be embedded in another Node app +- `packages/cli` can depend on `packages/server` +- host logic no longer lives in `packages/opencode` + +## PR sizing rule + +Every migration PR should satisfy all of these: + +- one route group or one to two endpoints +- no unrelated service refactor +- no auth redesign +- no middleware redesign +- OpenAPI updated +- at least one route test or spec test added or updated + +## Done means for a migrated route group + +A route group migration is complete only when: + +1. the `HttpApi` contract lives in `packages/server` +2. handler implementation lives in `packages/server` +3. the route is mounted from the current host in `packages/opencode` +4. the route appears in merged OpenAPI +5. request and response schemas are Effect Schema-first or clearly temporary placeholders +6. existing behavior remains unchanged +7. the route has straightforward test coverage + +## Validation expectations + +For package-split PRs, validate the smallest useful thing. + +Typical validation for the first waves: + +- `bun typecheck` in the touched package directory or directories +- the relevant route test, especially `test/server/question-httpapi.test.ts` +- merged OpenAPI coverage if the PR touches spec generation + +Do not run tests from repo root. + +## Main risks + +### Package cycle + +This is the biggest risk. + +Bad state: + +- `packages/server` imports services or runtime from `packages/opencode` +- `packages/opencode` imports route definitions or handlers from `packages/server` + +Avoid by: + +- keeping phase-1 `packages/server` free of `packages/opencode` imports +- using factories and host-provided wiring instead of direct service imports + +### Spec drift + +During the transition there are two route-definition sources. + +Avoid by: + +- one merged spec +- collision checks +- explicit `operationId`s +- merged OpenAPI tests + +### Middleware mismatch + +Current auth, compression, CORS, and instance selection are Hono-centered. + +Avoid by: + +- leaving them where they are during the first wave +- not trying to solve `HttpApiMiddleware.Service` globally in the package-split PRs + +### Core lag + +`packages/core` will not be ready everywhere. + +Avoid by: + +- allowing small transport-local placeholder schemas where necessary +- keeping those placeholders clearly temporary +- not blocking the server extraction on full schema movement + +### Scope creep + +The first vertical slice is easy to overload. + +Avoid by: + +- proving the package boundary first +- not mixing package creation, route migration, host redesign, and core extraction in the same change + +## Non-goals for the first wave + +- do not replace all Hono routes at once +- do not migrate SSE or websocket routes first +- do not redesign auth +- do not redesign instance lookup +- do not wait for full `packages/core` before starting `packages/server` +- do not change SDK generation to consume multiple specs + +## Checklist + +- [x] create `packages/server` +- [x] add package-level exports for contract and OpenAPI +- [ ] extract `question` contract into `packages/server` +- [ ] extract `question` handler factory into `packages/server` +- [ ] mount `question` from `packages/opencode` +- [ ] merge legacy and contract OpenAPI into one document +- [ ] add merged-spec coverage +- [ ] migrate `GET /provider/auth` +- [ ] migrate `GET /config/providers` +- [ ] migrate small read-only instance routes one or two at a time +- [ ] move host ownership into `packages/server` only after `packages/core` is ready enough +- [ ] split `packages/cli` after server and core boundaries are stable + +## Rule of thumb + +The fastest correct path is: + +1. establish `packages/server` as the contract-first boundary +2. keep `packages/opencode` as the temporary host +3. migrate a few safe JSON routes +4. keep one merged OpenAPI document +5. move actual host ownership only after `packages/core` can support it cleanly + +If a proposed PR would make `packages/server` import from `packages/opencode`, stop and restructure the boundary first. diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 0000000000..3b18792f46 --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/server", + "version": "1.4.3", + "type": "module", + "license": "MIT", + "exports": { + ".": "./src/index.ts", + "./openapi": "./src/openapi.ts", + "./definition": "./src/definition/index.ts", + "./definition/api": "./src/definition/api.ts", + "./api": "./src/api/index.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsc --noEmit", + "build": "tsc" + }, + "devDependencies": { + "typescript": "catalog:" + } +} diff --git a/packages/server/src/api/index.ts b/packages/server/src/api/index.ts new file mode 100644 index 0000000000..336ce12bb9 --- /dev/null +++ b/packages/server/src/api/index.ts @@ -0,0 +1 @@ +export {} diff --git a/packages/server/src/definition/api.ts b/packages/server/src/definition/api.ts new file mode 100644 index 0000000000..6eda4090e0 --- /dev/null +++ b/packages/server/src/definition/api.ts @@ -0,0 +1,6 @@ +import type { ServerApi } from "../types.js" + +export const api: ServerApi = { + name: "opencode", + groups: [], +} diff --git a/packages/server/src/definition/index.ts b/packages/server/src/definition/index.ts new file mode 100644 index 0000000000..39cab2446c --- /dev/null +++ b/packages/server/src/definition/index.ts @@ -0,0 +1 @@ +export { api } from "./api.js" diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 0000000000..2fbe31a0da --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,3 @@ +export { openapi } from "./openapi.js" +export { api } from "./definition/api.js" +export type { OpenApiSpec, ServerApi } from "./types.js" diff --git a/packages/server/src/openapi.ts b/packages/server/src/openapi.ts new file mode 100644 index 0000000000..c4ac953004 --- /dev/null +++ b/packages/server/src/openapi.ts @@ -0,0 +1,14 @@ +import { api } from "./definition/api.js" +import type { OpenApiSpec } from "./types.js" + +export function openapi(): OpenApiSpec { + return { + openapi: "3.1.1", + info: { + title: api.name, + version: "0.0.0", + description: "Contract-first server package scaffold.", + }, + paths: {}, + } +} diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts new file mode 100644 index 0000000000..8d337be42e --- /dev/null +++ b/packages/server/src/types.ts @@ -0,0 +1,14 @@ +export interface ServerApi { + readonly name: string + readonly groups: readonly string[] +} + +export interface OpenApiSpec { + readonly openapi: string + readonly info: { + readonly title: string + readonly version: string + readonly description: string + } + readonly paths: Record +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 0000000000..eac2af3845 --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "rootDir": "src", + "outDir": "dist", + "module": "nodenext", + "declaration": true, + "moduleResolution": "nodenext", + "lib": ["es2022", "dom", "dom.iterable"], + "strict": true, + "skipLibCheck": true + }, + "include": ["src"] +} From 3b2a2c461debb1afebe65378f978389d06152e1e Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 14 Apr 2026 18:07:31 -0400 Subject: [PATCH 5/6] sync zen --- .../app/src/routes/zen/util/handler.ts | 4 +- packages/console/core/src/model.ts | 71 +++++++++++++++++-- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 3e191918e5..8c391d590a 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -106,7 +106,7 @@ export async function handler( const zenData = ZenData.list(opts.modelList) const modelInfo = validateModel(zenData, model) const dataDumper = createDataDumper(sessionId, requestId, projectId) - const trialLimiter = createTrialLimiter(modelInfo.trialProviders, ip) + const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip) const trialProviders = await trialLimiter?.check() const rateLimiter = createRateLimiter( modelInfo.id, @@ -392,7 +392,7 @@ export async function handler( function validateModel(zenData: ZenData, reqModel: string) { if (!(reqModel in zenData.models)) throw new ModelError(t("zen.api.error.modelNotSupported", { model: reqModel })) - const modelId = reqModel as keyof typeof zenData.models + const modelId = reqModel const modelData = Array.isArray(zenData.models[modelId]) ? zenData.models[modelId].find((model) => opts.format === model.formatFilter) : zenData.models[modelId] diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index 3b24394316..b4149373fe 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -26,7 +26,7 @@ export namespace ZenData { allowAnonymous: z.boolean().optional(), byokProvider: z.enum(["openai", "anthropic", "google"]).optional(), stickyProvider: z.enum(["strict", "prefer"]).optional(), - trialProviders: z.array(z.string()).optional(), + trialProvider: z.string().optional(), trialEnded: z.boolean().optional(), fallbackProvider: z.string().optional(), rateLimit: z.number().optional(), @@ -45,7 +45,7 @@ export namespace ZenData { const ProviderSchema = z.object({ api: z.string(), - apiKey: z.string(), + apiKey: z.union([z.string(), z.record(z.string(), z.string())]), format: FormatSchema.optional(), headerMappings: z.record(z.string(), z.string()).optional(), payloadModifier: z.record(z.string(), z.any()).optional(), @@ -54,7 +54,10 @@ export namespace ZenData { }) const ModelsSchema = z.object({ - models: z.record(z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))])), + zenModels: z.record( + z.string(), + z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))]), + ), liteModels: z.record( z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))]), @@ -99,10 +102,66 @@ export namespace ZenData { Resource.ZEN_MODELS29.value + Resource.ZEN_MODELS30.value, ) - const { models, liteModels, providers } = ModelsSchema.parse(json) + const { zenModels, liteModels, providers } = ModelsSchema.parse(json) + const compositeProviders = Object.fromEntries( + Object.entries(providers).map(([id, provider]) => [ + id, + typeof provider.apiKey === "string" + ? [{ id: id, key: provider.apiKey }] + : Object.entries(provider.apiKey).map(([kid, key]) => ({ + id: `${id}.${kid}`, + key, + })), + ]), + ) return { - models: modelList === "lite" ? liteModels : models, - providers, + providers: Object.fromEntries( + Object.entries(providers).flatMap(([providerId, provider]) => + compositeProviders[providerId].map((p) => [p.id, { ...provider, apiKey: p.key }]), + ), + ), + models: (() => { + const normalize = (model: z.infer) => { + const composite = model.providers.find((p) => compositeProviders[p.id].length > 1) + if (!composite) + return { + trialProvider: model.trialProvider ? [model.trialProvider] : undefined, + } + + const weightMulti = compositeProviders[composite.id].length + + return { + trialProvider: (() => { + if (!model.trialProvider) return undefined + if (model.trialProvider === composite.id) return compositeProviders[composite.id].map((p) => p.id) + return [model.trialProvider] + })(), + providers: model.providers.flatMap((p) => + p.id === composite.id + ? compositeProviders[p.id].map((sub) => ({ + ...p, + id: sub.id, + weight: p.weight ?? 1, + })) + : [ + { + ...p, + weight: (p.weight ?? 1) * weightMulti, + }, + ], + ), + } + } + + return Object.fromEntries( + Object.entries(modelList === "lite" ? liteModels : zenModels).map(([modelId, model]) => { + const n = Array.isArray(model) + ? model.map((m) => ({ ...m, ...normalize(m) })) + : { ...model, ...normalize(model) } + return [modelId, n] + }), + ) + })(), } }) } From 60b8041ebbbed0081dc69f3be6abb0d1d2d119dc Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 14 Apr 2026 18:48:00 -0400 Subject: [PATCH 6/6] zen: support alibaba cache write --- .../app/src/routes/zen/util/provider/openai-compatible.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts index bdc12ba8be..cf9ee287c4 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts @@ -6,12 +6,14 @@ type Usage = { total_tokens?: number // used by moonshot cached_tokens?: number - // used by xai + // used by xai & alibaba prompt_tokens_details?: { text_tokens?: number audio_tokens?: number image_tokens?: number cached_tokens?: number + // used by alibaba + cache_creation_input_tokens?: number } completion_tokens_details?: { reasoning_tokens?: number @@ -62,6 +64,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif const outputTokens = usage.completion_tokens ?? 0 const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined let cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined + const cacheWriteTokens = usage.prompt_tokens_details?.cache_creation_input_tokens ?? undefined if (adjustCacheUsage && !cacheReadTokens) { cacheReadTokens = Math.floor(inputTokens * 0.9) @@ -72,7 +75,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif outputTokens, reasoningTokens, cacheReadTokens, - cacheWrite5mTokens: undefined, + cacheWrite5mTokens: cacheWriteTokens, cacheWrite1hTokens: undefined, } },