From 4c86c2a703d3f2ec96b6c5d9086529b63c168b17 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Sat, 3 Oct 2026 17:25:27 +0000 Subject: [PATCH] Daemon: OpenAI-compatible /v1/completions for Zed Parses Qwen-format FIM prompts (prefix- or suffix-first, optional repo/file headers) back into prefix/suffix and rebuilds them in the trained layout; mode from the model string (ac:symbols / ac:simple); files located via workspaceRoots by path or content. extensions/zed/README.md documents the Zed settings; untested against real Zed traffic yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J39imRDT8L7xYbWzsVWd8A --- README.md | 3 +- daemon/src/config.ts | 2 ++ daemon/src/main.ts | 41 +++++++++++++++++++++++ daemon/src/openai-shim.ts | 69 +++++++++++++++++++++++++++++++++++++++ daemon/test/smoke.ts | 6 +++- extensions/zed/README.md | 32 ++++++++++++++++++ 6 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 daemon/src/openai-shim.ts create mode 100644 extensions/zed/README.md diff --git a/README.md b/README.md index cb5b689..f7a912c 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ VS Code extension ──HTTP──▶ acd daemon ──▶ llama-server (llama.c |---|---| | [`models/`](models/) | GGUF models (Git LFS), `manifest.json`, model card, license | | [`extensions/vscode/`](extensions/vscode/) | the VS Code extension | +| [`extensions/zed/`](extensions/zed/) | Zed setup (settings for the daemon's OpenAI-compatible endpoint) | | [`daemon/`](daemon/) | `acd`, the completion daemon (bundled into the extension; also runs standalone) | | [`shared/`](shared/) | prompt layout + symbol extraction, shared with the training pipeline so prompts match training exactly | | [`scripts/`](scripts/) | publish models and extension releases | @@ -120,7 +121,7 @@ Running the daemon standalone (external mode) is covered in [daemon/README.md](d - **TypeScript errors as context:** the model sees nearby compiler errors and is trained on error → fix. - **Suggestions on cursor move:** predict what you came to a location to do, not just what you're typing. - Token healing for completions that start mid-identifier. -- Zed support via the daemon's OpenAI-compatible endpoint. +- Zed: the daemon's OpenAI-compatible endpoint is in place ([extensions/zed](extensions/zed/)); needs testing against real Zed traffic. ## License diff --git a/daemon/src/config.ts b/daemon/src/config.ts index b4fd870..e0f1868 100644 --- a/daemon/src/config.ts +++ b/daemon/src/config.ts @@ -58,6 +58,8 @@ export interface DaemonConfig { /** Where opted-in edit recordings from editors are appended (the opt-in is the editor setting). */ recording: { dir: string } stateFile: string + /** Workspace roots for clients that can't send one (Zed via /v1/completions): files are looked up under these. */ + workspaceRoots?: string[] /** Exit after this many minutes without any request (0 = never). Editors poll every 15 s while open. */ idleShutdownMinutes?: number /** Opaque tag reported by /health (the VS Code extension uses it to replace daemons left over from older versions). */ diff --git a/daemon/src/main.ts b/daemon/src/main.ts index b236df2..b7ec58d 100644 --- a/daemon/src/main.ts +++ b/daemon/src/main.ts @@ -9,6 +9,7 @@ * PUT /v1/config { enabled?, model?, mode? } (changing model restarts llama-server) * POST /v1/feedback { requestId, event: shown|accepted|partial|rejected, acceptedText? } * POST /v1/edits { events: [...] } opted-in edit recordings from editors -> recording.dir + * POST /v1/completions OpenAI-compatible (Zed): Qwen FIM prompt in, completion text out (prompt rebuilt in our layout) * POST /v1/prewarm same body as /v1/fim: prefill the prompt in the background (first keystroke warm) * POST /v1/next-edit { repo, path, workspaceRoot?, languageId?, text, cursor } -> { noEdit, startLine, endLine, newText } * GET /v1/debug/last?n=1 last requests with decoded prompts and raw output @@ -29,6 +30,7 @@ import { trimSuffixOverlap } from '../../shared/prompt.ts' import { symbolLang } from '../../shared/symbol-lang.ts' import { SymbolService, mightNeedThis } from './symbols.ts' import { EditTracker } from './edits.ts' +import { locateFile, modeFromModel, parseFimPrompt } from './openai-shim.ts' import { NEXT_EDIT_TOKENS } from '../../shared/nextedit.ts' const { values: args } = parseArgs({ options: { config: { type: 'string', default: 'acd.config.json' } } }) @@ -185,6 +187,44 @@ async function handleFim(body: FimRequest, res: http.ServerResponse, clientGone: json(res, 200, response) } +/** + * OpenAI-compatible /v1/completions for editors with built-in providers (Zed: edit_predictions.provider = + * "open_ai_compatible_api", prompt_format "qwen", model "ac:symbols"). The client's prompt is parsed back into + * prefix/suffix and rebuilt in our trained layout; the answer comes back in OpenAI text_completion format. + */ +async function handleOpenAiCompletion(body: Record, res: http.ServerResponse, clientGone: AbortSignal) { + const prompt = String(body.prompt ?? '') + const parsed = parseFimPrompt(prompt) ?? { prefix: prompt, suffix: '' } + const located = locateFile(config.workspaceRoots ?? [], parsed.path, parsed.prefix) + const fimBody: FimRequest = { + requestId: `oai-${Date.now()}`, + repo: located ? path.basename(located.root) : parsed.repo ?? 'workspace', + path: located?.path ?? parsed.path ?? 'untitled.ts', + workspaceRoot: located?.root, + languageId: /\.vue$/.test(located?.path ?? parsed.path ?? '') ? 'vue' : 'typescript', + prefix: parsed.prefix, + suffix: parsed.suffix, + mode: modeFromModel(body.model), + } + // Reuse the normal pipeline by capturing its JSON response. + let captured: Record = {} + const fake = { writeHead: () => fake, end: (s: string) => { captured = JSON.parse(s) }, writableEnded: false, destroyed: false } as unknown as http.ServerResponse + await handleFim(fimBody, fake, clientGone) + const text = String(captured.text ?? '') + const out = { + id: `cmpl-${Date.now()}`, object: 'text_completion', created: Math.floor(Date.now() / 1000), model: String(body.model ?? state.model), + choices: [{ text, index: 0, logprobs: null, finish_reason: 'stop' }], + usage: { prompt_tokens: (captured.timings as Record | undefined)?.promptTokens ?? 0, completion_tokens: (captured.timings as Record | undefined)?.genTokens ?? 0, total_tokens: 0 }, + } + if (body.stream) { + res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }) + res.write(`data: ${JSON.stringify(out)}\n\n`) + res.end('data: [DONE]\n\n') + return + } + json(res, 200, out) +} + /** Prefill a file's prompt in the background (e.g. on editor focus) so the first keystroke is warm. */ async function handlePrewarm(body: FimRequest, res: http.ServerResponse) { if (!state.enabled || loading || !llama.running) return json(res, 200, { warmed: false }) @@ -295,6 +335,7 @@ const server = http.createServer(async (req, res) => { return json(res, 200, describeConfig()) } if (route === 'POST /v1/fim') return await handleFim(await readBody(req) as FimRequest, res, gone.signal) + if (route === 'POST /v1/completions') return await handleOpenAiCompletion(await readBody(req) as Record, res, gone.signal) if (route === 'POST /v1/prewarm') return await handlePrewarm(await readBody(req) as FimRequest, res) if (route === 'POST /v1/next-edit') return await handleNextEdit(await readBody(req) as NextEditRequest, res, gone.signal) if (route === 'GET /v1/debug/last') { diff --git a/daemon/src/openai-shim.ts b/daemon/src/openai-shim.ts new file mode 100644 index 0000000..8c5440b --- /dev/null +++ b/daemon/src/openai-shim.ts @@ -0,0 +1,69 @@ +import fs from 'node:fs' +import path from 'node:path' + +/** + * Parses a FIM prompt built by another client (e.g. Zed's `open_ai_compatible_api` edit-prediction provider with + * prompt_format "qwen") back into prefix / suffix / file path, so the daemon can rebuild the prompt in the layout + * our models were trained on (suffix-first, repo header, symbols, healing). + * + * Accepts, in order of preference: + * [<|repo_name|>repo\n] [<|file_sep|>path\n …] <|fim_prefix|>{prefix}<|fim_suffix|>{suffix}<|fim_middle|> + * <|fim_prefix|><|fim_suffix|>{suffix}<|fim_middle|>{prefix} (suffix-first) + * Returns null for anything else (e.g. a plain completion prompt): the caller then treats it as prefix-only. + */ +export function parseFimPrompt(prompt: string): { prefix: string; suffix: string; path?: string; repo?: string } | null { + const repo = /<\|repo_name\|>([^\n<]*)\n/.exec(prompt)?.[1]?.trim() || undefined + const seps = [...prompt.matchAll(/<\|file_sep\|>([^\n<]*)\n/g)] + const filePath = seps.length ? seps.at(-1)![1].trim() || undefined : undefined + const psm = /<\|fim_prefix\|>([\s\S]*?)<\|fim_suffix\|>([\s\S]*?)<\|fim_middle\|>\s*$/.exec(prompt) + if (psm && !psm[1].includes('<|fim_suffix|>')) { + if (psm[1] === '') { + // Possibly suffix-first: "
suffixprefix"
+      const spm = /<\|fim_prefix\|><\|fim_suffix\|>([\s\S]*?)<\|fim_middle\|>([\s\S]*)$/.exec(prompt)
+      if (spm) return { prefix: spm[2], suffix: spm[1], path: filePath, repo }
+    }
+    return { prefix: psm[1], suffix: psm[2], path: filePath, repo }
+  }
+  const spm = /<\|fim_prefix\|><\|fim_suffix\|>([\s\S]*?)<\|fim_middle\|>([\s\S]*)$/.exec(prompt)
+  if (spm) return { prefix: spm[2], suffix: spm[1], path: filePath, repo }
+  return null
+}
+
+/** "ac:symbols" / "ac:simple" / "ac:raw" select the context mode; anything else keeps the current toggle. */
+export function modeFromModel(model: unknown): 'symbols' | 'simple' | 'raw' | undefined {
+  const m = /^ac:(symbols|simple|raw)$/.exec(String(model ?? ''))
+  return m ? (m[1] as 'symbols' | 'simple' | 'raw') : undefined
+}
+
+/**
+ * Finds the workspace for a request: the root containing `relPath` (if the client sent a path), else the root
+ * containing a file whose content starts with the prompt's prefix (Zed may not send a path).
+ */
+export function locateFile(roots: string[], relPath: string | undefined, prefix: string): { root: string; path: string } | null {
+  if (relPath) {
+    for (const root of roots) {
+      if (path.isAbsolute(relPath) && relPath.startsWith(root + path.sep)) return { root, path: path.relative(root, relPath).split(path.sep).join('/') }
+      if (fs.existsSync(path.join(root, relPath))) return { root, path: relPath }
+    }
+  }
+  const probe = prefix.slice(0, 400)
+  if (probe.length < 40) return null
+  for (const root of roots) {
+    const stack = [root]
+    let visited = 0
+    while (stack.length && visited < 5000) {
+      const dir = stack.pop()!
+      for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+        if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'dist') continue
+        const p = path.join(dir, e.name)
+        if (e.isDirectory()) stack.push(p)
+        else if (/\.(ts|tsx|js|jsx|vue|mts|cts)$/.test(e.name) && ++visited) {
+          try {
+            if (fs.readFileSync(p, 'utf8').startsWith(probe)) return { root, path: path.relative(root, p).split(path.sep).join('/') }
+          } catch { /* unreadable */ }
+        }
+      }
+    }
+  }
+  return null
+}
diff --git a/daemon/test/smoke.ts b/daemon/test/smoke.ts
index e0ebe91..f9df73c 100644
--- a/daemon/test/smoke.ts
+++ b/daemon/test/smoke.ts
@@ -124,6 +124,10 @@ if (cfg.models?.['nextedit-plumbing']) {
   for (let i = 0; i < 120 && !(await api('GET', '/health')).body.ok; i++) await new Promise(r => setTimeout(r, 500))
 }
 
+// ---------- OpenAI-compatible endpoint (Zed)
+const oai = (await api('POST', '/v1/completions', { model: 'ac:simple', prompt: "<|fim_prefix|>import { z } from 'zod'\n\nexport const configSchema = z.object({\n  port: z.coerce.number().default(3000),\n})\n\nexport type Config = <|fim_suffix|>\n<|fim_middle|>", max_tokens: 64 })).body
+check('OpenAI-compatible /v1/completions (Zed shim)', oai.object === 'text_completion' && String(oai.choices?.[0]?.text).includes('z.infer'), JSON.stringify(oai.choices?.[0]?.text))
+
 // ---------- edit recording endpoint
 const recorded = await api('POST', '/v1/edits', { events: [{ t: Date.now(), repo: 'smoke', path: 'src/a.ts', version: 2, changes: [{ offset: 0, length: 0, text: 'x' }], snapshot: 'x' }] })
 check('edit recordings are accepted', recorded.body.recorded === 1)
@@ -132,7 +136,7 @@ check('edit recordings are accepted', recorded.body.recorded === 1)
 // Optional typing replay over held-out FIM samples from the (private) training workspace.
 const dataDir = path.resolve(process.env.SMOKE_DATA_DIR ?? '../../data')
 const testFim = path.join(dataDir, 'fim/test-repo.jsonl')
-if (fs.existsSync(testFim)) {
+if (fs.existsSync(testFim) && Number(args['typing-samples']) > 0) {
   const files = new Map(fs.readFileSync(path.join(dataDir, 'corpus/files.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l)).map(r => [r.id, r]))
   const rank = (id: string) => crypto.createHash('sha256').update(`smoke:${id}`).digest('hex')
   const samples = fs.readFileSync(testFim, 'utf8').trim().split('\n').map(l => JSON.parse(l))
diff --git a/extensions/zed/README.md b/extensions/zed/README.md
new file mode 100644
index 0000000..09b3a85
--- /dev/null
+++ b/extensions/zed/README.md
@@ -0,0 +1,32 @@
+# Zed
+
+Zed can't load third-party edit-prediction extensions, but its built-in `open_ai_compatible_api` provider can call
+the daemon's OpenAI-compatible endpoint. The daemon parses Zed's Qwen-format FIM prompt back into prefix/suffix and
+rebuilds it in the layout our models were trained on (suffix-first, repo header, symbols, token healing).
+
+1. Run the daemon standalone (see [daemon/README.md](../../daemon/README.md)). Add your project folders to
+   `"workspaceRoots"` in its config, so symbols mode and the `this.` repair can find the files. Zed may not send
+   the file path; the daemon then locates the file by content.
+2. In Zed's `settings.json`:
+
+```jsonc
+{
+  "edit_predictions": {
+    "provider": "open_ai_compatible_api",
+    "open_ai_compatible_api": {
+      "api_url": "http://127.0.0.1:7071/v1/completions",
+      "model": "ac:symbols",          // the context-mode toggle: "ac:symbols" | "ac:simple"
+      "prompt_format": "qwen"
+    }
+  }
+}
+```
+
+The mode can also be switched with `node daemon/src/acctl.ts mode simple`.
+
+**Status: untested against real Zed traffic.** The parser accepts prefix-first and suffix-first Qwen prompts, with
+or without `<|repo_name|>`/`<|file_sep|>` headers, and is covered by the daemon smoke test with a synthetic request.
+Still to check with a logging proxy:
+- whether Zed sends the file path
+- its prefix/suffix truncation
+- whether it streams and cancels superseded requests